From b33d0eb850b3747c07c23d49ca8a1ea4747eb6cf Mon Sep 17 00:00:00 2001 From: marcel Date: Fri, 12 Jun 2026 14:04:02 +0200 Subject: [PATCH] feat(lesereisen): implement lesereisen --- CLAUDE.md | 25 +- backend/CLAUDE.md | 3 +- .../familienarchiv/audit/AuditKind.java | 24 +- .../document/DocumentController.java | 4 +- .../document/DocumentDeletingEvent.java | 11 + .../document/DocumentRepository.java | 7 + .../document/DocumentService.java | 32 +- .../familienarchiv/exception/ErrorCode.java | 16 + .../exception/GlobalExceptionHandler.java | 9 +- .../familienarchiv/geschichte/Geschichte.java | 28 +- .../geschichte/GeschichteController.java | 70 +- .../geschichte/GeschichteQueryService.java | 29 + .../geschichte/GeschichteRepository.java | 37 +- .../geschichte/GeschichteService.java | 138 ++- .../geschichte/GeschichteSpecifications.java | 20 +- .../geschichte/GeschichteSummary.java | 45 + .../geschichte/GeschichteType.java | 6 + .../geschichte/GeschichteUpdateDTO.java | 3 +- .../geschichte/GeschichteView.java | 41 + .../geschichte/PersonNameFormatter.java | 22 + .../journeyitem/DocumentSummary.java | 23 + .../geschichte/journeyitem/JourneyItem.java | 54 ++ .../journeyitem/JourneyItemCreateDTO.java | 12 + .../JourneyItemDocumentDeleteListener.java | 30 + .../journeyitem/JourneyItemRepository.java | 69 ++ .../journeyitem/JourneyItemService.java | 276 ++++++ .../journeyitem/JourneyItemUpdateDTO.java | 19 + .../journeyitem/JourneyItemView.java | 17 + .../journeyitem/JourneyReorderDTO.java | 12 + ...ey_items_migrate_geschichten_documents.sql | 73 ++ ...add_journey_items_position_constraints.sql | 19 + ...y_items_document_dedup_and_note_length.sql | 37 + .../V75__geschichten_journey_intro_length.sql | 16 + .../document/DocumentControllerTest.java | 1 + .../document/DocumentLazyLoadingTest.java | 22 + .../document/DocumentServiceSortTest.java | 8 +- .../document/DocumentServiceTest.java | 10 +- .../geschichte/GeschichteConstraintsTest.java | 66 ++ .../geschichte/GeschichteControllerTest.java | 283 +++++- .../geschichte/GeschichteHttpTest.java | 298 +++++++ .../GeschichteListProjectionTest.java | 262 ++++++ .../GeschichteQueryServiceTest.java | 38 + .../GeschichteServiceIntegrationTest.java | 33 +- .../geschichte/GeschichteServiceTest.java | 488 +++++++++-- .../JourneyItemConstraintsTest.java | 165 ++++ .../JourneyItemDocumentDeleteTest.java | 261 ++++++ .../JourneyItemIntegrationTest.java | 418 +++++++++ .../journeyitem/JourneyItemServiceTest.java | 822 ++++++++++++++++++ docs/ARCHITECTURE.md | 4 +- docs/DEPLOYMENT.md | 2 + docs/GLOSSARY.md | 15 +- ...tional-string-three-way-patch-semantics.md | 43 + ...hichte-responses-are-views-not-entities.md | 65 ++ ...ey-items-serve-both-geschichte-subtypes.md | 78 ++ ...omain-event-driven-journey-item-cleanup.md | 118 +++ .../c4/l3-backend-3g-supporting.puml | 13 +- .../c4/l3-frontend-3c-people-stories.puml | 8 +- docs/architecture/db/db-orm.puml | 20 +- docs/architecture/db/db-relationships.puml | 8 +- .../geschichten-reader-journey-spec.html | 19 +- docs/specs/lesereisen-editor-spec.html | 16 +- docs/specs/lesereisen-reader-spec.html | 29 +- frontend/.gitignore | 3 + frontend/.prettierignore | 2 + frontend/messages/de.json | 80 +- frontend/messages/en.json | 80 +- frontend/messages/es.json | 80 +- .../DashboardNeedsMetadata.svelte.spec.ts | 2 +- .../lib/document/DocumentMultiSelect.svelte | 97 +-- .../DocumentMultiSelect.svelte.spec.ts | 23 + .../document/DocumentPickerDropdown.svelte | 155 ++++ .../DocumentPickerDropdown.svelte.spec.ts | 261 ++++++ .../src/lib/document/documentTypeahead.ts | 45 + .../TranscriptionEditView.svelte | 2 +- frontend/src/lib/generated/api.ts | 394 +++++++-- .../lib/geschichte/GeschichteEditor.svelte | 90 +- .../GeschichteEditor.svelte.spec.ts | 81 +- .../lib/geschichte/GeschichteListRow.svelte | 85 ++ .../GeschichteListRow.svelte.spec.ts | 94 ++ .../lib/geschichte/GeschichteSidebar.svelte | 82 ++ .../GeschichteSidebar.svelte.spec.ts | 40 + .../src/lib/geschichte/GeschichtenCard.svelte | 14 +- .../geschichte/GeschichtenCard.svelte.spec.ts | 16 +- .../geschichte/GeschichtenCard.svelte.test.ts | 33 +- .../src/lib/geschichte/JourneyAddBar.svelte | 126 +++ .../geschichte/JourneyAddBar.svelte.spec.ts | 72 ++ .../src/lib/geschichte/JourneyEditor.svelte | 408 +++++++++ .../geschichte/JourneyEditor.svelte.spec.ts | 813 +++++++++++++++++ .../lib/geschichte/JourneyInterlude.svelte | 18 + .../JourneyInterlude.svelte.spec.ts | 64 ++ .../src/lib/geschichte/JourneyItemCard.svelte | 66 ++ .../geschichte/JourneyItemCard.svelte.spec.ts | 188 ++++ .../src/lib/geschichte/JourneyItemRow.svelte | 268 ++++++ .../geschichte/JourneyItemRow.svelte.spec.ts | 354 ++++++++ .../src/lib/geschichte/JourneyReader.svelte | 53 ++ .../geschichte/JourneyReader.svelte.spec.ts | 174 ++++ frontend/src/lib/geschichte/README.md | 45 +- .../lib/geschichte/StoryDocumentPanel.svelte | 221 +++++ .../StoryDocumentPanel.svelte.spec.ts | 446 ++++++++++ .../src/lib/geschichte/StoryReader.svelte | 123 +++ .../lib/geschichte/StoryReader.svelte.spec.ts | 134 +++ frontend/src/lib/geschichte/utils.test.ts | 65 ++ frontend/src/lib/geschichte/utils.ts | 39 + .../src/lib/person/PersonMultiSelect.svelte | 3 +- frontend/src/lib/person/personFormat.ts | 14 + frontend/src/lib/person/personOption.ts | 27 + .../src/lib/shared/actions/radioGroupNav.ts | 3 +- .../dashboard/ReaderDraftsModule.svelte | 4 +- .../ReaderDraftsModule.svelte.spec.ts | 9 +- .../dashboard/ReaderRecentStories.svelte | 4 +- .../ReaderRecentStories.svelte.spec.ts | 9 +- frontend/src/lib/shared/errors.ts | 24 + .../hooks}/useBlockDragDrop.svelte.test.ts | 30 +- .../hooks}/useBlockDragDrop.svelte.ts | 11 +- .../shared/hooks/useTypeahead.svelte.test.ts | 50 ++ .../lib/shared/hooks/useTypeahead.svelte.ts | 14 +- .../src/lib/shared/utils/extractText.spec.ts | 12 + frontend/src/lib/shared/utils/extractText.ts | 17 +- frontend/src/routes/+page.server.ts | 10 +- .../src/routes/SearchFilterBar.svelte.spec.ts | 7 + .../src/routes/geschichten/+page.server.ts | 27 +- frontend/src/routes/geschichten/+page.svelte | 189 ++-- .../geschichten/DocumentFilterChip.svelte | 35 + .../DocumentFilterChip.svelte.spec.ts | 87 ++ .../src/routes/geschichten/[id]/+page.svelte | 185 ++-- .../routes/geschichten/[id]/edit/+page.svelte | 27 +- .../geschichten/[id]/edit/page.svelte.test.ts | 56 +- .../geschichten/[id]/page.svelte.test.ts | 214 ++++- .../routes/geschichten/new/+page.server.ts | 25 +- .../src/routes/geschichten/new/+page.svelte | 56 +- .../geschichten/new/JourneyCreate.svelte | 90 ++ .../new/JourneyCreate.svelte.spec.ts | 89 ++ .../routes/geschichten/new/StoryCreate.svelte | 58 ++ .../new/StoryCreate.svelte.spec.ts | 16 + .../geschichten/new/TypeSelector.svelte | 97 +++ .../new/TypeSelector.svelte.spec.ts | 123 +++ .../geschichten/new/page.server.test.ts | 77 ++ .../geschichten/new/page.svelte.test.ts | 66 +- .../routes/geschichten/page.server.test.ts | 193 ++++ .../routes/geschichten/page.svelte.spec.ts | 232 ++++- .../routes/geschichten/page.svelte.test.ts | 25 +- frontend/src/routes/layout.css | 69 ++ 142 files changed, 11643 insertions(+), 917 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDeletingEvent.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemDocumentDeleteListener.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java create mode 100644 backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql create mode 100644 backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql create mode 100644 backend/src/main/resources/db/migration/V74__journey_items_document_dedup_and_note_length.sql create mode 100644 backend/src/main/resources/db/migration/V75__geschichten_journey_intro_length.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteConstraintsTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemDocumentDeleteTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java create mode 100644 docs/adr/035-optional-string-three-way-patch-semantics.md create mode 100644 docs/adr/036-geschichte-responses-are-views-not-entities.md create mode 100644 docs/adr/037-journey-items-serve-both-geschichte-subtypes.md create mode 100644 docs/adr/038-domain-event-driven-journey-item-cleanup.md create mode 100644 frontend/src/lib/document/DocumentPickerDropdown.svelte create mode 100644 frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts create mode 100644 frontend/src/lib/document/documentTypeahead.ts create mode 100644 frontend/src/lib/geschichte/GeschichteListRow.svelte create mode 100644 frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/GeschichteSidebar.svelte create mode 100644 frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyAddBar.svelte create mode 100644 frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyEditor.svelte create mode 100644 frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyInterlude.svelte create mode 100644 frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyItemCard.svelte create mode 100644 frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyItemRow.svelte create mode 100644 frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyReader.svelte create mode 100644 frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/StoryDocumentPanel.svelte create mode 100644 frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/StoryReader.svelte create mode 100644 frontend/src/lib/geschichte/StoryReader.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/utils.test.ts create mode 100644 frontend/src/lib/geschichte/utils.ts create mode 100644 frontend/src/lib/person/personOption.ts rename frontend/src/lib/{document/transcription => shared/hooks}/useBlockDragDrop.svelte.test.ts (79%) rename frontend/src/lib/{document/transcription => shared/hooks}/useBlockDragDrop.svelte.ts (91%) create mode 100644 frontend/src/routes/geschichten/DocumentFilterChip.svelte create mode 100644 frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts create mode 100644 frontend/src/routes/geschichten/new/JourneyCreate.svelte create mode 100644 frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts create mode 100644 frontend/src/routes/geschichten/new/StoryCreate.svelte create mode 100644 frontend/src/routes/geschichten/new/StoryCreate.svelte.spec.ts create mode 100644 frontend/src/routes/geschichten/new/TypeSelector.svelte create mode 100644 frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts create mode 100644 frontend/src/routes/geschichten/new/page.server.test.ts create mode 100644 frontend/src/routes/geschichten/page.server.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8364399a..ae9127c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/ │ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService ├── exception/ DomainException, ErrorCode, GlobalExceptionHandler ├── filestorage/ FileService (S3/MinIO) -├── geschichte/ Geschichte (story) domain +├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService +│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController ├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader ├── notification/ Notification domain + SseEmitterRegistry ├── ocr/ OCR domain — OcrService, OcrBatchService, training @@ -105,13 +106,15 @@ backend/src/main/java/org/raddatz/familienarchiv/ ### Domain Model -| Entity | Table | Key relationships | -| ----------- | ------------- | ------------------------------------------------------------------------------------- | -| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) | -| `Person` | `persons` | Referenced by documents as sender/receiver | -| `Tag` | `tag` | ManyToMany with documents via `document_tags` | -| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) | -| `UserGroup` | `user_groups` | Has a `Set 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 permissions` | +| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) | +| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` | **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` @@ -152,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional ### DTOs -Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs). +Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen. - `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation. @@ -160,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). +**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints). ### Security / Permissions @@ -268,7 +271,7 @@ Back button pattern — use the shared `` component from `$lib/share → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). +**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints). --- diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index b96d242a..38d5b08b 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java index 62f04874..b666c8d8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -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 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 ); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java index beb98256..5aa58ca7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -168,8 +168,8 @@ public class DocumentController { @DeleteMapping("/{id}") @RequirePermission(Permission.WRITE_ALL) - public ResponseEntity deleteDocument(@PathVariable UUID id) { - documentService.deleteDocument(id); + public ResponseEntity deleteDocument(@PathVariable UUID id, Authentication authentication) { + documentService.deleteDocument(id, requireUserId(authentication)); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDeletingEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDeletingEvent.java new file mode 100644 index 00000000..ed30aa91 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDeletingEvent.java @@ -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) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index 1cfe26f6..72a9fd03 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository, JpaSp @EntityGraph("Document.list") Page 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 findByIdIn(Collection ids); + // Findet ein Dokument anhand des ursprünglichen Dateinamens // Wichtig für den Abgleich beim Excel-Import & Datei-Upload Optional findByOriginalFilename(String originalFilename); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 4f69922c..61b578de 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -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 rankMap = new HashMap<>(); List 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 docs = documentRepository.findAllById(pageIds).stream() + List 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). + * + *

Security contract — read before calling: + *

    + *
  1. 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.
  2. + *
  3. 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.
  4. + *
+ * 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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 3eb5287d..84caab27 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -122,6 +122,22 @@ public enum ErrorCode { // --- Geschichten (Stories) --- /** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */ GESCHICHTE_NOT_FOUND, + /** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */ + JOURNEY_ITEM_NOT_FOUND, + /** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */ + JOURNEY_ITEM_POSITION_CONFLICT, + /** The journey already has the maximum allowed number of items (100). 400 */ + JOURNEY_AT_CAPACITY, + /** The document is already present in this journey — duplicate items are not allowed. 409 */ + JOURNEY_DOCUMENT_ALREADY_ADDED, + /** The type of an existing Geschichte cannot be changed via PATCH. 409 */ + GESCHICHTE_TYPE_IMMUTABLE, + /** A journey-item note exceeds the maximum length (2000 characters). 400 */ + JOURNEY_NOTE_TOO_LONG, + /** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */ + GESCHICHTE_TITLE_TOO_LONG, + /** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */ + GESCHICHTE_INTRO_TOO_LONG, // --- Tags --- /** A tag with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index 686ef457..c56cc576 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -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")); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java index a50679f2..e6188cdc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java @@ -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 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 documents = new HashSet<>(); + private List items = new ArrayList<>(); @CreationTimestamp @Column(updatable = false) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java index 1da96278..604cc034 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java @@ -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 list( + public List 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 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 create(@RequestBody GeschichteUpdateDTO dto) { - Geschichte created = geschichteService.create(dto); + public ResponseEntity 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 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 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 reorderItems( + @PathVariable UUID id, + @RequestBody JourneyReorderDTO dto) { + return journeyItemService.reorder(id, dto); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java new file mode 100644 index 00000000..cde834bd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java @@ -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 findById(UUID id) { + return geschichteRepository.findById(id); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java index b42a47a5..6fe40a8e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java @@ -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, JpaSpecificationExecutor { + + /** + * Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false). + * + *

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. + * + *

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 findSummaries( + @Param("effectiveStatus") GeschichteStatus effectiveStatus, + @Param("authorId") UUID authorId, + @Param("personIds") Collection personIds, + @Param("personCount") long personCount, + @Param("documentId") UUID documentId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index 53443cf4..cbc8f6d1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -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,57 @@ public class GeschichteService { return g; } + @Transactional(readOnly = true) + public GeschichteView getView(UUID id) { + Geschichte g = getById(id); + List items = journeyItemService.getItems(id); + return toView(g, items); + } + + GeschichteView toView(Geschichte g, List 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 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}. + * + *

Returns a {@link GeschichteSummary} projection — never carries items, preventing + * LazyInitializationException on the non-transactional list path. */ - public List list(GeschichteStatus status, List personIds, UUID documentId, int limit) { + public List list(GeschichteStatus status, List personIds, UUID documentId, int limit) { GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; - Specification spec = Specification.allOf( - GeschichteSpecifications.hasStatus(effective), - GeschichteSpecifications.hasAuthor(authorId), - GeschichteSpecifications.hasAllPersons(personIds), - GeschichteSpecifications.hasDocument(documentId), - GeschichteSpecifications.orderByDisplayDateDesc() - ); - return geschichteRepository.findAll(spec, Sort.unsorted()) + + // When personIds is empty, personCount=0 short-circuits the IN() predicate. + // Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped. + Collection 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 +140,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 +218,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 +251,6 @@ public class GeschichteService { return new LinkedHashSet<>(personService.getAllById(ids)); } - private Set resolveDocuments(List ids) { - if (ids == null || ids.isEmpty()) return new HashSet<>(); - Set 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()) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java index 42797ffe..b8ff3603 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java @@ -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 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 documentSubquery( - Root root, CriteriaQuery query, CriteriaBuilder cb, UUID documentId) { - Subquery sub = query.subquery(UUID.class); - Root subRoot = sub.from(Geschichte.class); - Join 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; - } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java new file mode 100644 index 00000000..d8fb9be1 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java @@ -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. + * + *

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(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java new file mode 100644 index 00000000..57b7fb27 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.geschichte; + +public enum GeschichteType { + STORY, + JOURNEY +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java index bd05d568..c2b78597 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java @@ -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 personIds; - private List documentIds; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java new file mode 100644 index 00000000..814e4038 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java @@ -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 persons, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List 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 + ) {} +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java new file mode 100644 index 00000000..91c63231 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java @@ -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; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java new file mode 100644 index 00000000..17ba1848 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java @@ -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 +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java new file mode 100644 index 00000000..d57d7c09 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java @@ -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. + * + *

CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.

+ */ + @Column(columnDefinition = "TEXT") + private String note; + + // JPA uses field access — this getter is not persisted. Jackson serializes it as documentId. + // Exposing only the UUID prevents circular references and large nested payloads. + public UUID getDocumentId() { + return document != null ? document.getId() : null; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java new file mode 100644 index 00000000..9a7c420d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemDocumentDeleteListener.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemDocumentDeleteListener.java new file mode 100644 index 00000000..01a7cd4e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemDocumentDeleteListener.java @@ -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()); + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java new file mode 100644 index 00000000..7f7d38ab --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -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 { + + /** Returns items ordered by position ASC for the read-model assembly path. */ + List findByGeschichteIdOrderByPosition(UUID geschichteId); + + /** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */ + Optional 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 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 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 findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java new file mode 100644 index 00000000..70e46718 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -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 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 reorder(UUID geschichteId, JourneyReorderDTO dto) { + if (!geschichteQueryService.existsById(geschichteId)) { + throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, + "Geschichte not found: " + geschichteId); + } + Set existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId); + List 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 items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId); + Map itemMap = new HashMap<>(); + for (JourneyItem item : items) { + itemMap.put(item.getId(), item); + } + + List 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 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 getItems(UUID geschichteId) { + return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId) + .stream().map(this::toView).toList(); + } + + DocumentSummary toSummary(Document doc) { + String senderName = buildSenderName(doc); + Set 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 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()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java new file mode 100644 index 00000000..1e63ac9c --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java @@ -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: + * 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 note = null; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java new file mode 100644 index 00000000..1e443299 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java @@ -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 +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java new file mode 100644 index 00000000..b11f343b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java @@ -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 itemIds; +} diff --git a/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql b/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql new file mode 100644 index 00000000..3757e267 --- /dev/null +++ b/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql @@ -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; diff --git a/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql b/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql new file mode 100644 index 00000000..76a8af2f --- /dev/null +++ b/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V74__journey_items_document_dedup_and_note_length.sql b/backend/src/main/resources/db/migration/V74__journey_items_document_dedup_and_note_length.sql new file mode 100644 index 00000000..062e0d6d --- /dev/null +++ b/backend/src/main/resources/db/migration/V74__journey_items_document_dedup_and_note_length.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V75__geschichten_journey_intro_length.sql b/backend/src/main/resources/db/migration/V75__geschichten_journey_intro_length.sql new file mode 100644 index 00000000..3444a78f --- /dev/null +++ b/backend/src/main/resources/db/migration/V75__geschichten_journey_intro_length.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index b655e0bf..15934986 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -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()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java index 6768991f..1197577c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java @@ -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"); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java index bbc058ac..e432c71b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java @@ -81,7 +81,7 @@ class DocumentServiceSortTest { UUID id1 = UUID.randomUUID(); List 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 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), diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 023b2003..eadad6ac 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -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 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 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( diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteConstraintsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteConstraintsTest.java new file mode 100644 index 00000000..ff0baa60 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteConstraintsTest.java @@ -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", "

" + "x".repeat(4001) + "

"); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id); + assertThat(count).isEqualTo(1); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index 4d13363b..853c6e86 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -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("

x

") - .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, "

x

", + 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; } + }; } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java new file mode 100644 index 00000000..3c324b32 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java @@ -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. + * + *

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 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 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 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 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 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 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 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 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; + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java new file mode 100644 index 00000000..49c31bf8 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 sentinel() { + return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java new file mode 100644 index 00000000..8cdc84db --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index 31c73af1..5cef25e0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -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 { + ""); 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("jeden Sonntag") .doesNotContain(""); - Geschichte saved = geschichteService.create(dto); + GeschichteView saved = geschichteService.create(dto); - assertThat(saved.getBody()) + assertThat(saved.body()) .contains("

safe

") .doesNotContain(""); + + GeschichteView saved = geschichteService.update(id, dto); + + assertThat(saved.body()).doesNotContain(""); - Geschichte saved = geschichteService.update(id, dto); + GeschichteView saved = geschichteService.update(id, dto); - assertThat(saved.getBody()).doesNotContain(" @@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string { {/each} -
(showDropdown = false)}> +
picker.close()}>
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string { - {formatDocLabel(doc)} + {formatDocumentOption(doc)}
- {#if showDropdown && (results.length > 0 || loading)} + {#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
- {#if loading} + {#if picker.loading}
{m.comp_multiselect_loading()}
+ {:else if picker.error} + {:else} - {#each results as doc (doc.id)} + {#each filteredResults as doc (doc.id)}
selectDocument(doc)} @@ -161,7 +118,7 @@ function formatDocLabel(doc: DocumentOption): string { role="button" tabindex="0" > - {formatDocLabel(doc)} + {formatDocumentOption(doc)}
{/each} {/if} diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts b/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts index d348026c..b8af8aac 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import DocumentMultiSelect from './DocumentMultiSelect.svelte'; +import { m } from '$lib/paraglide/messages.js'; const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); @@ -124,6 +125,28 @@ describe('DocumentMultiSelect — search and select', () => { }); }); +describe('DocumentMultiSelect — search failure', () => { + it('shows an error row when the search request fails instead of looking like "no results"', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' }) + }) + ); + + render(DocumentMultiSelect); + + await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug'); + await waitForDebounce(); + + const alert = page.getByRole('alert'); + await expect.element(alert).toBeInTheDocument(); + await expect.element(alert).toHaveTextContent(m.comp_typeahead_error()); + }); +}); + describe('DocumentMultiSelect — remove', () => { it('removes a chip when its × button is clicked', async () => { render(DocumentMultiSelect, { diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte b/frontend/src/lib/document/DocumentPickerDropdown.svelte new file mode 100644 index 00000000..b3fa4a2a --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte @@ -0,0 +1,155 @@ + + +
picker.close()} class="relative"> + 0 + ? listboxId + : undefined} + aria-autocomplete="list" + aria-activedescendant={activeOptionId} + placeholder={placeholder} + value={inputValue} + oninput={handleInput} + onkeydown={handleKeydown} + class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + /> + + {#if picker.isOpen} + {#if picker.loading} +
+

{m.comp_multiselect_loading()}

+
+ {:else if picker.error} +
+ +
+ {:else if picker.results.length === 0} +
+

{m.comp_typeahead_no_results()}

+
+ {:else} +
    + {#each picker.results as doc, i (doc.id)} + {@const disabled = alreadyAddedIds.has(doc.id!)} + +
  • handleSelect(doc)} + class={[ + 'px-3 py-2 text-ink select-none', + i === picker.activeIndex ? 'bg-muted' : '', + disabled + ? 'cursor-default opacity-50' + : 'cursor-pointer hover:bg-muted' + ].join(' ')} + > + {formatDocumentOption(doc)} + {#if disabled} + {m.journey_already_added()} + {/if} +
  • + {/each} +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts new file mode 100644 index 00000000..5ac389fb --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import DocumentPickerDropdown from './DocumentPickerDropdown.svelte'; +import { m } from '$lib/paraglide/messages.js'; + +const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); + +const docFactory = (id: string, title: string) => ({ + id, + title, + documentDate: '1880-01-01', + metaDatePrecision: 'DAY' as const, + metaDateEnd: undefined +}); + +function mockSearchResponse(items: ReturnType[]) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ items }) + }) + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('DocumentPickerDropdown — empty query guard', () => { + it('does not call fetch on empty query', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + await userEvent.fill(page.getByRole('combobox'), ''); + await waitForDebounce(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('DocumentPickerDropdown — already-added indicator', () => { + it('shows already-added document as aria-disabled with sr-only hint', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); + + render(DocumentPickerDropdown, { + alreadyAddedIds: new Set(['d1']), + onSelect: vi.fn() + }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + + const disabledItem = page + .getByText(/Brief von Eugenie/i) + .element() + .closest('li')!; + expect(disabledItem.getAttribute('aria-disabled')).toBe('true'); + // Screen-reader text "bereits enthalten" must be present in the item + await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument(); + }); +}); + +describe('DocumentPickerDropdown — selection', () => { + it('calls onSelect with the item when a non-disabled option is clicked', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { onSelect }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.click(page.getByText(/Brief von Eugenie/i)); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); + }); + + it('does not call onSelect when an aria-disabled option is clicked', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { + alreadyAddedIds: new Set(['d1']), + onSelect + }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await page.getByText(/Brief von Eugenie/i).click({ force: true }); + + expect(onSelect).not.toHaveBeenCalled(); + }); +}); + +describe('DocumentPickerDropdown — keyboard navigation', () => { + it('selects the first option via ArrowDown then Enter', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); + + render(DocumentPickerDropdown, { onSelect }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); + }); + + it('does not select an aria-disabled option on Enter', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { + alreadyAddedIds: new Set(['d1']), + onSelect + }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('closes the dropdown on Escape', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await expect.element(page.getByRole('listbox')).toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + }); + + it('points aria-activedescendant at the active option', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + const input = page.getByRole('combobox'); + await userEvent.fill(input, 'Brief'); + await waitForDebounce(); + + expect(input.element().getAttribute('aria-activedescendant')).toBeNull(); + + await userEvent.keyboard('{ArrowDown}'); + + const activeId = input.element().getAttribute('aria-activedescendant'); + expect(activeId).toMatch(/-option-0$/); + const firstOption = page + .getByText(/Brief von Eugenie/i) + .element() + .closest('li')!; + expect(firstOption.id).toBe(activeId); + expect(firstOption.getAttribute('aria-selected')).toBe('true'); + }); +}); + +describe('DocumentPickerDropdown — no results', () => { + it('shows a non-interactive no-results row when the search returns zero hits', async () => { + mockSearchResponse([]); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'xyz'); + await waitForDebounce(); + + await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument(); + }); +}); + +describe('DocumentPickerDropdown — search failure', () => { + it('shows an error message when the search request fails instead of vanishing', async () => { + // 500 from /api/documents/search — must surface, not render as "no results" + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' }) + }) + ); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + + await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument(); + }); +}); + +describe('DocumentPickerDropdown — ARIA listbox integrity', () => { + it('does not render a listbox when results are empty (no aria-required-children violation)', async () => { + mockSearchResponse([]); + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'xyz'); + await waitForDebounce(); + + // no-results message must be visible, but NOT inside a listbox + await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument(); + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); + + it('does not render a listbox when loading (no aria-required-children violation)', async () => { + let resolveSearch!: (v: unknown) => void; + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(new Promise((resolve) => (resolveSearch = resolve))) + ); + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + + // While in-flight, no listbox should exist + expect(document.querySelector('[role="listbox"]')).toBeNull(); + resolveSearch({ ok: true, json: () => Promise.resolve({ items: [] }) }); + }); + + it('option elements do not have tabindex (combobox pattern: focus stays on input)', async () => { + mockSearchResponse([docFactory('d1', 'Brief A'), docFactory('d2', 'Brief B')]); + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + + const options = document.querySelectorAll('[role="listbox"] [role="option"]'); + expect(options.length).toBeGreaterThan(0); + options.forEach((opt) => { + expect(opt).not.toHaveAttribute('tabindex'); + }); + }); +}); + +describe('DocumentPickerDropdown — external label wiring (#795)', () => { + it('renders a generated default id on the input and keeps the aria-label fallback', async () => { + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + const input = page.getByRole('combobox').element() as HTMLInputElement; + expect(input.id).toMatch(/^doc-picker-input-/); + expect(input.getAttribute('aria-label')).not.toBeNull(); + }); + + it('uses the provided inputId and drops the aria-label so an external label wins', async () => { + render(DocumentPickerDropdown, { onSelect: vi.fn(), inputId: 'story-doc-picker' }); + + const input = page.getByRole('combobox').element() as HTMLInputElement; + expect(input.id).toBe('story-doc-picker'); + expect(input.getAttribute('aria-label')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/document/documentTypeahead.ts b/frontend/src/lib/document/documentTypeahead.ts new file mode 100644 index 00000000..1f244d28 --- /dev/null +++ b/frontend/src/lib/document/documentTypeahead.ts @@ -0,0 +1,45 @@ +import type { components } from '$lib/generated/api'; +import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte'; +import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; +import { getLocale } from '$lib/paraglide/runtime.js'; + +type DocumentListItem = components['schemas']['DocumentListItem']; + +export type DocumentOption = Pick< + DocumentListItem, + 'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd' +>; + +export function createDocumentTypeahead() { + return createTypeahead({ + fetchUrl: (q) => + fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`) + .then((r) => { + // Without this check a 401/500 parses as JSON without `items` and + // renders as "no results" — errors must reach the hook's error state. + if (!r.ok) throw new Error(`document search failed: ${r.status}`); + return r.json(); + }) + .then((b: { items: DocumentListItem[] }) => + b.items.map((it) => ({ + id: it.id, + title: it.title, + documentDate: it.documentDate, + metaDatePrecision: it.metaDatePrecision, + metaDateEnd: it.metaDateEnd + })) + ) + }); +} + +export function formatDocumentOption(doc: DocumentOption): string { + if (!doc.documentDate) return doc.title; + const label = formatDocumentDate( + doc.documentDate, + doc.metaDatePrecision as DatePrecision, + doc.metaDateEnd, + null, + getLocale() + ); + return `${doc.title} · ${label}`; +} diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte index 4d764a61..a0f00812 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte @@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte'; import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte'; import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; -import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; +import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte'; import { csrfFetch } from '$lib/shared/cookies'; type Props = { diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index efe723b4..7c33f707 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -84,6 +84,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/geschichten/{id}/items/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * 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. + */ + put: operations["reorderItems"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{id}": { parameters: { query?: never; @@ -420,6 +440,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/geschichten/{id}/items": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appendItem"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents": { parameters: { query?: never; @@ -804,6 +840,22 @@ export interface paths { patch: operations["update"]; trace?: never; }; + "/api/geschichten/{id}/items/{itemId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteItem"]; + options?: never; + head?: never; + patch: operations["updateItemNote"]; + trace?: never; + }; "/api/documents/{id}/training-labels": { parameters: { query?: never; @@ -875,7 +927,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_1"]; + get: operations["search"]; put?: never; post?: never; delete?: never; @@ -1339,7 +1391,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_2"]; + get: operations["search_1"]; put?: never; post?: never; delete?: never; @@ -1690,6 +1742,32 @@ export interface components { provisional: boolean; readonly displayName: string; }; + JourneyReorderDTO: { + itemIds?: string[]; + }; + DocumentSummary: { + /** Format: uuid */ + id: string; + title: string; + /** Format: date */ + documentDate?: string; + /** Format: date */ + documentDateEnd?: string; + /** @enum {string} */ + datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + senderName?: string; + receiverName?: string; + /** Format: int32 */ + receiverCount: number; + }; + JourneyItemView: { + /** Format: uuid */ + id: string; + /** Format: int32 */ + position: number; + document?: components["schemas"]["DocumentSummary"]; + note?: string; + }; DocumentUpdateDTO: { title?: string; /** Format: date */ @@ -1819,75 +1897,6 @@ export interface components { /** Format: uuid */ targetId: string; }; - Pageable: { - /** Format: int32 */ - page?: number; - /** Format: int32 */ - size?: number; - sort?: string[]; - }; - ActivityActorDTO: { - initials: string; - color: string; - name?: string; - }; - DocumentListItem: { - /** Format: uuid */ - id: string; - title: string; - originalFilename: string; - thumbnailUrl?: string; - /** Format: date */ - documentDate?: string; - /** @enum {string} */ - metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; - /** Format: date */ - metaDateEnd?: string; - sender?: components["schemas"]["Person"]; - receivers: components["schemas"]["Person"][]; - tags: components["schemas"]["Tag"][]; - archiveBox?: string; - archiveFolder?: string; - location?: string; - summary?: string; - /** Format: int32 */ - completionPercentage: number; - contributors: components["schemas"]["ActivityActorDTO"][]; - matchData: components["schemas"]["SearchMatchData"]; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - DocumentSearchResult: { - items: components["schemas"]["DocumentListItem"][]; - /** Format: int64 */ - totalElements: number; - /** Format: int32 */ - pageNumber: number; - /** Format: int32 */ - pageSize: number; - /** Format: int32 */ - totalPages: number; - /** Format: int64 */ - undatedCount: number; - }; - MatchOffset: { - /** Format: int32 */ - start: number; - /** Format: int32 */ - length: number; - }; - SearchMatchData: { - transcriptionSnippet?: string; - titleOffsets: components["schemas"]["MatchOffset"][]; - senderMatched: boolean; - matchedReceiverIds: string[]; - matchedTagIds: string[]; - snippetOffsets: components["schemas"]["MatchOffset"][]; - summarySnippet?: string; - summaryOffsets: components["schemas"]["MatchOffset"][]; - }; CreateRelationshipRequest: { /** Format: uuid */ relatedPersonId: string; @@ -2015,25 +2024,44 @@ export interface components { body?: string; /** @enum {string} */ status?: "DRAFT" | "PUBLISHED"; + /** @enum {string} */ + type?: "STORY" | "JOURNEY"; personIds?: string[]; - documentIds?: string[]; }; - Geschichte: { + AuthorView: { + /** Format: uuid */ + id: string; + displayName: string; + }; + GeschichteView: { /** Format: uuid */ id: string; title: string; body?: string; /** @enum {string} */ status: "DRAFT" | "PUBLISHED"; - author?: components["schemas"]["AppUser"]; - persons?: components["schemas"]["Person"][]; - documents?: components["schemas"]["Document"][]; + /** @enum {string} */ + type: "STORY" | "JOURNEY"; + author?: components["schemas"]["AuthorView"]; + persons: components["schemas"]["PersonView"][]; + items: components["schemas"]["JourneyItemView"][]; + /** Format: date-time */ + publishedAt?: string; /** Format: date-time */ createdAt: string; /** Format: date-time */ updatedAt: string; - /** Format: date-time */ - publishedAt?: string; + }; + PersonView: { + /** Format: uuid */ + id: string; + firstName?: string; + lastName?: string; + }; + JourneyItemCreateDTO: { + /** Format: uuid */ + documentId?: string; + note?: string; }; CreateTranscriptionBlockDTO: { /** Format: int32 */ @@ -2233,6 +2261,9 @@ export interface components { actorName?: string; documentTitle?: string; }; + JourneyItemUpdateDTO: { + note?: string; + }; TrainingLabelRequest: { label?: string; enrolled?: boolean; @@ -2273,6 +2304,11 @@ export interface components { /** Format: int64 */ transcriptionCount: number; }; + ActivityActorDTO: { + initials: string; + color: string; + name?: string; + }; TranscriptionQueueItemDTO: { /** Format: uuid */ id: string; @@ -2335,13 +2371,13 @@ export interface components { lastName?: string; /** Format: int64 */ documentCount?: number; + alias?: string; notes?: string; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; provisional?: boolean; - alias?: string; personType?: string; familyMember?: boolean; }; @@ -2440,6 +2476,8 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -2448,8 +2486,6 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; - first?: boolean; - last?: boolean; empty?: boolean; }; PageableObject: { @@ -2472,6 +2508,25 @@ export interface components { nodes: components["schemas"]["PersonNodeDTO"][]; edges: components["schemas"]["RelationshipDTO"][]; }; + AuthorSummary: { + firstName?: string; + lastName?: string; + }; + GeschichteSummary: { + body?: string; + title: string; + /** Format: uuid */ + id: string; + /** @enum {string} */ + type: "STORY" | "JOURNEY"; + /** @enum {string} */ + status: "DRAFT" | "PUBLISHED"; + /** Format: date-time */ + updatedAt: string; + author?: components["schemas"]["AuthorSummary"]; + /** Format: date-time */ + publishedAt?: string; + }; DocumentVersionSummary: { /** Format: uuid */ id: string; @@ -2513,6 +2568,63 @@ export interface components { /** Format: int32 */ totalPages?: number; }; + DocumentListItem: { + /** Format: uuid */ + id: string; + title: string; + originalFilename: string; + thumbnailUrl?: string; + /** Format: date */ + documentDate?: string; + /** @enum {string} */ + metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + metaDateEnd?: string; + sender?: components["schemas"]["Person"]; + receivers: components["schemas"]["Person"][]; + tags: components["schemas"]["Tag"][]; + archiveBox?: string; + archiveFolder?: string; + location?: string; + summary?: string; + /** Format: int32 */ + completionPercentage: number; + contributors: components["schemas"]["ActivityActorDTO"][]; + matchData: components["schemas"]["SearchMatchData"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + DocumentSearchResult: { + items: components["schemas"]["DocumentListItem"][]; + /** Format: int64 */ + totalElements: number; + /** Format: int32 */ + pageNumber: number; + /** Format: int32 */ + pageSize: number; + /** Format: int32 */ + totalPages: number; + /** Format: int64 */ + undatedCount: number; + }; + MatchOffset: { + /** Format: int32 */ + start: number; + /** Format: int32 */ + length: number; + }; + SearchMatchData: { + transcriptionSnippet?: string; + titleOffsets: components["schemas"]["MatchOffset"][]; + senderMatched: boolean; + matchedReceiverIds: string[]; + matchedTagIds: string[]; + snippetOffsets: components["schemas"]["MatchOffset"][]; + summarySnippet?: string; + summaryOffsets: components["schemas"]["MatchOffset"][]; + }; IncompleteDocumentDTO: { /** Format: uuid */ id: string; @@ -2561,7 +2673,7 @@ export interface components { }; ActivityFeedItemDTO: { /** @enum {string} */ - kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED"; + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED"; actor?: components["schemas"]["ActivityActorDTO"]; /** Format: uuid */ documentId: string; @@ -2871,6 +2983,32 @@ export interface operations { }; }; }; + reorderItems: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyReorderDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"][]; + }; + }; + }; + }; getDocument: { parameters: { query?: never; @@ -3574,9 +3712,13 @@ export interface operations { list: { parameters: { query?: { + /** @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. */ status?: "DRAFT" | "PUBLISHED"; + /** @description AND-filter: story must include all supplied person IDs. */ personId?: string[]; + /** @description Filter to stories containing this document. */ documentId?: string; + /** @description Maximum results to return. Values ≤ 0 default to 50. Clamped at 200. */ limit?: number; }; header?: never; @@ -3591,7 +3733,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"][]; + "*/*": components["schemas"]["GeschichteSummary"][]; }; }; }; @@ -3615,7 +3757,33 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"]; + "*/*": components["schemas"]["GeschichteView"]; + }; + }; + }; + }; + appendItem: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyItemCreateDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"]; }; }; }; @@ -4291,7 +4459,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"]; + "*/*": components["schemas"]["GeschichteView"]; }; }; }; @@ -4337,7 +4505,55 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"]; + "*/*": components["schemas"]["GeschichteView"]; + }; + }; + }; + }; + deleteItem: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + itemId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateItemNote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + itemId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyItemUpdateDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"]; }; }; }; @@ -4486,7 +4702,7 @@ export interface operations { }; }; }; - search_1: { + search: { parameters: { query?: { q?: string; @@ -5110,7 +5326,7 @@ export interface operations { }; }; }; - search_2: { + search_1: { parameters: { query?: { q?: string; @@ -5325,7 +5541,7 @@ export interface operations { query?: { limit?: number; /** @description Filter by audit kinds; omit for all rollup-eligible kinds */ - kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[]; + kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[]; }; header?: never; path?: never; diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte b/frontend/src/lib/geschichte/GeschichteEditor.svelte index 1df78c03..e7bb7daf 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte @@ -5,34 +5,26 @@ import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; -import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte'; -import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte'; +import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte'; +import { toPersonOption, type PersonOption } from '$lib/person/personOption'; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteView = components['schemas']['GeschichteView']; type Person = components['schemas']['Person']; -type Document = components['schemas']['Document']; interface Props { - geschichte?: Geschichte | null; + geschichte?: GeschichteView | null; initialPersons?: Person[]; - initialDocuments?: Document[]; + /** Must reject when the save failed — the editor keeps its dirty state then. */ onSubmit: (payload: { title: string; body: string; status: 'DRAFT' | 'PUBLISHED'; personIds: string[]; - documentIds: string[]; }) => Promise; submitting?: boolean; } -let { - geschichte = null, - initialPersons = [], - initialDocuments = [], - onSubmit, - submitting = false -}: Props = $props(); +let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props(); // Initial-state snapshot from incoming props. The editor owns these values // after mount; the parent should re-mount the component with a different @@ -41,11 +33,8 @@ let { let title = $state(geschichte?.title ?? ''); let body = $state(geschichte?.body ?? ''); let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT'); -let selectedPersons: Person[] = $state( - geschichte?.persons ? Array.from(geschichte.persons) : initialPersons -); -let selectedDocuments: Document[] = $state( - geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments +let selectedPersons: PersonOption[] = $state( + geschichte?.persons ? Array.from(geschichte.persons).map(toPersonOption) : initialPersons ); let dirty = $state(false); @@ -118,14 +107,17 @@ function handleTitleInput() { async function save(nextStatus: 'DRAFT' | 'PUBLISHED') { titleTouched = true; if (titleEmpty) return; - await onSubmit({ - title: title.trim(), - body, - status: nextStatus, - personIds: selectedPersons.map((p) => p.id!).filter(Boolean), - documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean) - }); - dirty = false; + try { + await onSubmit({ + title: title.trim(), + body, + status: nextStatus, + personIds: selectedPersons.map((p) => p.id!).filter(Boolean) + }); + dirty = false; + } catch { + // onSubmit signalled failure — keep dirty so the unsaved guard stays armed + } } function isActive(name: string, attrs?: Record): boolean { @@ -148,6 +140,7 @@ function exec(action: () => void) { void) {
- +
diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts index 3593af8e..d47490c8 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; import GeschichteEditor from './GeschichteEditor.svelte'; const personFactory = (id: string, displayName: string) => ({ @@ -8,19 +9,9 @@ const personFactory = (id: string, displayName: string) => ({ firstName: displayName.split(' ')[0], lastName: displayName.split(' ').slice(1).join(' ') || displayName, displayName, - personType: 'PERSON' as const -}); - -const docFactory = (id: string, title: string, date = '1882-01-01') => ({ - id, - title, - documentDate: date, - originalFilename: `${title}.pdf`, - status: 'UPLOADED' as const, - metadataComplete: false, - scriptType: 'UNKNOWN' as const, - createdAt: '2024-01-01T00:00:00', - updatedAt: '2024-01-01T00:00:00' + personType: 'PERSON' as const, + familyMember: false, + provisional: false }); const draftFactory = (overrides: Record = {}) => ({ @@ -28,8 +19,9 @@ const draftFactory = (overrides: Record = {}) => ({ title: 'Existing draft', body: '

Hello world

', status: 'DRAFT' as const, + type: 'STORY' as const, persons: [], - documents: [], + items: [], createdAt: '2024-01-01T00:00:00', updatedAt: '2024-01-01T00:00:00', ...overrides @@ -63,6 +55,22 @@ describe('GeschichteEditor — title-required guard', () => { }); }); +describe('GeschichteEditor — onSubmit rejects on failure', () => { + it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => { + // Contract: onSubmit rejects on failure. Without the catch in save(), this + // click would surface as an unhandled promise rejection and fail the run. + const onSubmit = vi.fn().mockRejectedValue(new Error('save failed')); + render(GeschichteEditor, { geschichte: draftFactory(), onSubmit }); + + await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' })); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + // Editor still functional — a second save attempt goes through + await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' })); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + }); +}); + describe('GeschichteEditor — save bar adapts to status', () => { it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => { render(GeschichteEditor, { onSubmit: vi.fn() }); @@ -93,14 +101,6 @@ describe('GeschichteEditor — pre-fill', () => { await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument(); }); - it('renders initial documents as chips', async () => { - render(GeschichteEditor, { - initialDocuments: [docFactory('d1', 'Brief von Eugenie')], - onSubmit: vi.fn() - }); - await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument(); - }); - it('populates the title input from a geschichte prop', async () => { render(GeschichteEditor, { geschichte: draftFactory({ title: 'My existing story' }), @@ -154,11 +154,10 @@ describe('GeschichteEditor — onSubmit payload', () => { expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED'); }); - it('passes the personIds and documentIds from initial props through onSubmit', async () => { + it('passes personIds from initial props through onSubmit', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render(GeschichteEditor, { initialPersons: [personFactory('p1', 'Franz Raddatz')], - initialDocuments: [docFactory('d1', 'Brief A')], onSubmit }); @@ -171,6 +170,38 @@ describe('GeschichteEditor — onSubmit payload', () => { expect(onSubmit).toHaveBeenCalledTimes(1); const payload = onSubmit.mock.calls[0][0]; expect(payload.personIds).toEqual(['p1']); - expect(payload.documentIds).toEqual(['d1']); + }); +}); + +describe('GeschichteEditor — story document panel (#795)', () => { + it('shows the document panel with the story items when editing an existing story', async () => { + render(GeschichteEditor, { + geschichte: draftFactory({ + items: [ + { + id: 'i1', + position: 10, + document: { + id: 'd1', + title: 'Brief von Eugenie', + datePrecision: 'DAY' as const, + receiverCount: 0 + } + } + ] + }), + onSubmit: vi.fn().mockResolvedValue(undefined) + }); + + await expect + .element(page.getByRole('heading', { name: m.geschichte_documents_heading() })) + .toBeInTheDocument(); + await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument(); + }); + + it('hides the document panel when no geschichte is set (creation flow)', async () => { + render(GeschichteEditor, { onSubmit: vi.fn() }); + + expect(document.body.textContent).not.toContain(m.geschichte_documents_heading()); }); }); diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte b/frontend/src/lib/geschichte/GeschichteListRow.svelte new file mode 100644 index 00000000..0138443b --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte @@ -0,0 +1,85 @@ + + + + + + + +
+ +
+ + + {authorName} + {#if publishedAt} + {publishedAt} + {/if} +
+ +
+

+ {geschichte.title} +

+ {#if isJourney} + + {m.journey_badge_list()} + + {/if} +
+ {#if geschichte.body} + +

+ {plainExcerpt(geschichte.body, 150)} +

+ {/if} +
+
diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts new file mode 100644 index 00000000..52babd9c --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte'); + +afterEach(cleanup); + +const baseRow = (overrides = {}) => ({ + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923...

', + type: 'STORY' as 'STORY' | 'JOURNEY', + status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT', + author: { firstName: 'Anna', lastName: 'Schmidt' }, + publishedAt: '2026-04-15T10:00:00Z', + ...overrides +}); + +describe('GeschichteListRow', () => { + it('renders the title', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + await expect + .element(page.getByRole('heading', { level: 2 })) + .toHaveTextContent('Die Reise nach Berlin'); + }); + + it('row text sizes suit the full-width list: title text-lg, excerpt/meta text-sm (#802)', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + + const title = document.querySelector('h2'); + expect(title!.className).toContain('text-lg'); + expect(title!.className).not.toContain('text-[15px]'); + + const excerpt = document.querySelector('p'); + expect(excerpt!.className).toContain('text-sm'); + expect(excerpt!.className).not.toContain('text-xs'); + + const meta = Array.from(document.querySelectorAll('span')).filter((s) => + s.textContent?.includes('Anna Schmidt') + ); + expect(meta.length).toBeGreaterThan(0); + for (const span of meta) { + expect(span.className).toContain('text-sm'); + } + }); + + it('desktop meta column is wide enough for text-sm names (w-40, #802)', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + + const metaColumn = document.querySelector('[class*="border-r"]'); + expect(metaColumn).not.toBeNull(); + expect(metaColumn!.className).toContain('w-40'); + expect(metaColumn!.className).not.toContain('w-28'); + }); + + it('shows no badge for STORY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows no badge when type is undefined', async () => { + render(GeschichteListRow, { + props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) } + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows REISE badge for JOURNEY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent?.trim()).toBe('REISE'); + }); + + it('badge is a plain , not a nested interactive element', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge?.tagName.toLowerCase()).toBe('span'); + }); + + it('badge uses the 12px label size — text-xs is the visible-text floor', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge!.className).toContain('text-xs'); + // 10px was below the house floor for the 60+ audience (round-3 review) + expect(badge!.className).not.toContain('text-[10px]'); + }); + + it('renders author name in meta line', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + expect(document.body.textContent).toContain('Anna Schmidt'); + }); +}); diff --git a/frontend/src/lib/geschichte/GeschichteSidebar.svelte b/frontend/src/lib/geschichte/GeschichteSidebar.svelte new file mode 100644 index 00000000..53bf1020 --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteSidebar.svelte @@ -0,0 +1,82 @@ + + + diff --git a/frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts new file mode 100644 index 00000000..841a0da9 --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import GeschichteSidebar from './GeschichteSidebar.svelte'; + +const item = { + id: 'i1', + position: 10, + document: { + id: 'd1', + title: 'Brief von Eugenie', + datePrecision: 'DAY' as const, + receiverCount: 0 + } +}; + +afterEach(() => cleanup()); + +describe('GeschichteSidebar — document panel contract (#795)', () => { + it('renders the document panel when geschichteId and items are provided', async () => { + render(GeschichteSidebar, { + status: 'DRAFT', + selectedPersons: [], + geschichteId: 'g1', + items: [item] + }); + + await expect + .element(page.getByRole('heading', { name: m.geschichte_documents_heading() })) + .toBeInTheDocument(); + await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument(); + }); + + it('does not render the document panel without geschichteId', async () => { + render(GeschichteSidebar, { status: 'DRAFT', selectedPersons: [] }); + + expect(document.body.textContent).not.toContain(m.geschichte_documents_heading()); + }); +}); diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte b/frontend/src/lib/geschichte/GeschichtenCard.svelte index 2fec7b39..3b6349f3 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte @@ -3,11 +3,12 @@ import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; import { plainExcerpt } from '$lib/shared/utils/extractText'; import { formatDate } from '$lib/shared/utils/date'; +import { formatAuthorName } from './utils'; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; interface Props { - geschichten: Geschichte[]; + geschichten: GeschichteSummary[]; personId: string; personName: string; canWrite: boolean; @@ -18,16 +19,13 @@ let { geschichten, personId, personName, canWrite }: Props = $props(); const visible = $derived(geschichten.slice(0, 3)); const hasOverflow = $derived(geschichten.length >= 3); -function formatPublishedDate(g: Geschichte): string | null { +function formatPublishedDate(g: GeschichteSummary): string | null { if (!g.publishedAt) return null; return formatDate(g.publishedAt.slice(0, 10), 'short'); } -function authorName(g: Geschichte): string { - const a = g.author; - if (!a) return ''; - const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim(); - return full || a.email || ''; +function authorName(g: GeschichteSummary): string { + return formatAuthorName(g.author); } diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts index c73f66d9..fd1ddcbc 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts @@ -3,19 +3,19 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import GeschichtenCard from './GeschichtenCard.svelte'; -const makeStory = (id: string, title: string, body: string | null = '

Body

') => ({ +const makeStory = (id: string, title: string, body: string | undefined = '

Body

') => ({ id, title, body, status: 'PUBLISHED' as const, + type: 'STORY' as const, publishedAt: '2024-04-01T12:00:00', createdAt: '2024-03-01T12:00:00', updatedAt: '2024-04-01T12:00:00', persons: [], - documents: [], + items: [], author: { id: 'u1', - email: 'marcel@example.com', firstName: 'Marcel', lastName: 'Raddatz', enabled: true, @@ -120,6 +120,16 @@ describe('GeschichtenCard', () => { expect(link.getAttribute('href')).toBe('/geschichten?personId=p1'); }); + it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => { + render(GeschichtenCard, { + geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }], + personId: 'p1', + personName: 'Franz', + canWrite: false + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + it('renders a plain-text excerpt without HTML markup', async () => { render(GeschichtenCard, { geschichten: [ diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts index e3212cbf..34611cd3 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts @@ -2,20 +2,29 @@ import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import GeschichtenCard from './GeschichtenCard.svelte'; +import type { components } from '$lib/generated/api'; + +type GeschichteSummary = components['schemas']['GeschichteSummary']; afterEach(cleanup); -const makeGeschichte = (overrides: Record = {}) => ({ - id: 'g1', - title: 'Reise nach Berlin', - body: '

Brief text

', - publishedAt: '2026-04-15T10:00:00Z', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown, - ...overrides -}); +const makeGeschichte = (overrides: Record = {}): GeschichteSummary => + ({ + id: 'g1', + title: 'Reise nach Berlin', + body: '

Brief text

', + status: 'PUBLISHED' as const, + type: 'STORY' as const, + publishedAt: '2026-04-15T10:00:00Z', + author: { + firstName: 'Anna', + lastName: 'Schmidt' + }, + ...overrides + }) as GeschichteSummary; const baseProps = (overrides: Record = {}) => ({ - geschichten: [] as ReturnType[], + geschichten: [] as GeschichteSummary[], personId: 'p-1', personName: 'Anna Schmidt', canWrite: false, @@ -93,17 +102,17 @@ describe('GeschichtenCard', () => { await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); }); - it('falls back to author email when no name', async () => { + it('falls back to [Unbekannt] when no name', async () => { render(GeschichtenCard, { props: baseProps({ geschichten: [ makeGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@x' } + author: { firstName: undefined, lastName: undefined } }) ] }) }); - await expect.element(page.getByText(/fallback@x/)).toBeVisible(); + await expect.element(page.getByText('[Unbekannt]')).toBeVisible(); }); }); diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte b/frontend/src/lib/geschichte/JourneyAddBar.svelte new file mode 100644 index 00000000..22576e24 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte @@ -0,0 +1,126 @@ + + +
+
+ + +
+ + {#if showPicker} +
+ +
+ {/if} + + {#if showInterludeForm} +
+ +
+ + +
+
+ {/if} +
diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts new file mode 100644 index 00000000..9fd250e7 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import JourneyAddBar from './JourneyAddBar.svelte'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('JourneyAddBar — interlude flow', () => { + it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => { + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + + const confirmBtn = page.getByRole('button', { + name: m.journey_add_interlude_confirm(), + exact: true + }); + await expect.element(confirmBtn).toBeDisabled(); + }); + + it('confirm becomes enabled after typing text', async () => { + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise'); + + const confirmBtn = page.getByRole('button', { + name: m.journey_add_interlude_confirm(), + exact: true + }); + await expect.element(confirmBtn).toBeEnabled(); + }); + + it('calls onAddInterlude with text on confirm', async () => { + const onAddInterlude = vi.fn(); + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude }); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien'); + await userEvent.click( + page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) + ); + + expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien'); + }); + + it('limits the interlude textarea to 2000 characters', async () => { + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + + await expect.element(page.getByRole('textbox')).toHaveAttribute('maxlength', '2000'); + }); +}); + +describe('JourneyAddBar — document picker', () => { + it('reveals picker when "Brief hinzufügen" is clicked', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) }) + ); + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); + + await userEvent.click(page.getByText(m.journey_add_document())); + + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte new file mode 100644 index 00000000..516aaa8e --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -0,0 +1,408 @@ + + + +
{liveAnnounce}
+ +{#if unsaved.showUnsavedWarning} + +{/if} + +
+ +
+ +
+ unsaved.markDirty()} + onblur={() => (titleTouched = true)} + placeholder={m.geschichte_editor_title_placeholder()} + aria-label={m.journey_title_aria_label()} + aria-invalid={showTitleError} + aria-describedby={showTitleError ? 'journey-title-error' : undefined} + class="block w-full rounded border {showTitleError + ? 'border-danger' + : 'border-line'} bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + /> + {#if showTitleError} + + {/if} +
+ + +
+ +

{m.journey_intro_save_hint()}

+
+ + + {#if showPublishedEmptyWarning} +

+ {m.journey_published_empty_warning()} +

+ {/if} + + {#if mutationError} + + {/if} + + {#if items.length === 0} +

{m.journey_empty_state()}

+ {:else} + +
    dragDrop.handlePointerMove(e)} + onpointerup={() => dragDrop.handlePointerUp()} + class="m-0 flex list-none flex-col gap-2 p-0" + > + {#each items as item, i (item.id)} + +
  1. dragDrop.handleGripDown(e, item.id)} + class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}" + style={dragDrop.draggedBlockId === item.id + ? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;` + : ''} + > + {#if dragDrop.dropTargetIdx === i} +
    + {/if} + handleMoveUp(i)} + onMoveDown={() => handleMoveDown(i)} + onRemove={() => handleRemove(item.id)} + onNotePatch={(note) => handleNotePatch(item.id, note)} + /> +
  2. + {/each} +
+ {/if} + + +
+ + + +
+ + +
+

+ {isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()} +

+
+
+ {#if isDraft} + + + {:else} + + + {/if} +
+ {#if isDraft && !canPublish} +

{m.journey_publish_disabled_hint()}

+ {/if} +
+
diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts new file mode 100644 index 00000000..3ffb5af7 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -0,0 +1,813 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import JourneyEditor from './JourneyEditor.svelte'; + +vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); +import { beforeNavigate } from '$app/navigation'; + +const docSummary = (id: string, title: string) => ({ + id, + title, + datePrecision: 'DAY' as const, + receiverCount: 0 +}); + +/** DocumentListItem fixture as returned by the picker search endpoint. */ +const makeSearchResultItem = (id: string, title: string) => ({ + id, + title, + documentDate: '1880-01-01', + metaDatePrecision: 'DAY', + originalFilename: 'brief.pdf', + receivers: [], + tags: [], + completionPercentage: 0, + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + status: 'UPLOADED', + metadataComplete: false, + scriptType: 'UNKNOWN', + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' +}); + +const makeGeschichte = (overrides: Record = {}) => ({ + id: 'g1', + title: 'Briefe der Familie Raddatz', + body: '', + status: 'DRAFT' as const, + type: 'JOURNEY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00', + ...overrides +}); + +const defaultProps = (overrides: Record = {}) => ({ + geschichte: makeGeschichte(), + onSubmit: vi.fn().mockResolvedValue(undefined), + submitting: false, + ...overrides +}); + +function mockCsrfFetch(responseFactory: () => object) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(responseFactory()) + }) + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('JourneyEditor — empty state', () => { + it('renders title input and intro textarea', async () => { + render(JourneyEditor, defaultProps()); + await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument(); + await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument(); + }); + + it('labels the title input and intro textarea for screen readers', async () => { + render(JourneyEditor, defaultProps()); + await expect + .element(page.getByRole('textbox', { name: m.journey_title_aria_label() })) + .toBeInTheDocument(); + await expect + .element(page.getByRole('textbox', { name: m.journey_intro_aria_label() })) + .toBeInTheDocument(); + }); + + it('shows empty state message when items list is empty', async () => { + render(JourneyEditor, defaultProps()); + await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument(); + }); +}); + +describe('JourneyEditor — items in position order', () => { + it('renders items sorted by position', async () => { + const items = [ + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }, + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') } + ]; + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + // Brief A (position 0) must appear before Brief B (position 1) in DOM order + const briefA = page.getByText('Brief A').element(); + const briefB = page.getByText('Brief B').element(); + expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); + +describe('JourneyEditor — publish surface', () => { + it('publish button disabled when no items', async () => { + render(JourneyEditor, defaultProps()); + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); + }); + + it('shows a visible hint while publishing is disabled', async () => { + render(JourneyEditor, defaultProps()); + await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument(); + }); + + it('publish stays disabled until title is non-empty', async () => { + render( + JourneyEditor, + defaultProps({ + geschichte: makeGeschichte({ + title: '', + items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] + }) + }) + ); + + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); + + const titleInput = page.getByPlaceholder(/Titel/); + await userEvent.fill(titleInput, 'Meine Reise'); + + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); + }); + + it('adding an item enables the publish button (canPublish becomes true)', async () => { + const newItem = { id: 'i1', position: 0, note: 'Test' }; + mockCsrfFetch(() => newItem); + + render(JourneyEditor, defaultProps()); + + // Publish should be disabled before adding item + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); + + // Add interlude + await userEvent.click(page.getByText(m.journey_add_interlude())); + await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test'); + await userEvent.click( + page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) + ); + + // After item add, publish becomes enabled — item was added and state is correct + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); + }); + + it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + JourneyEditor, + defaultProps({ + onSubmit, + geschichte: makeGeschichte({ + title: ' Meine Reise ', + items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] + }) + }) + ); + + await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ })); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' }) + ); + }); + }); + + it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + JourneyEditor, + defaultProps({ + onSubmit, + geschichte: makeGeschichte({ + status: 'PUBLISHED', + items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] + }) + }) + ); + + await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() })); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' })); + }); + }); + + it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => { + render( + JourneyEditor, + defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) }) + ); + + await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument(); + }); +}); + +describe('JourneyEditor — add document', () => { + it('calls POST with documentId when document selected from picker', async () => { + const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }; + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + // picker search results + ok: true, + json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] }) + }) + .mockResolvedValueOnce({ + // POST /items + ok: true, + json: vi.fn().mockResolvedValue(newItem) + }) + ); + + render(JourneyEditor, defaultProps()); + + await userEvent.click(page.getByText(m.journey_add_document())); + await userEvent.fill(page.getByRole('combobox'), 'Karl'); + // dropdown option appears after the typeahead debounce + await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument(); + await userEvent.click(page.getByText(/Brief von Karl ·/)); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items'), + expect.objectContaining({ method: 'POST' }) + ); + }); + }); +}); + +describe('JourneyEditor — add interlude', () => { + it('calls POST with note on interlude confirm', async () => { + const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' }; + mockCsrfFetch(() => newItem); + + render(JourneyEditor, defaultProps()); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + await userEvent.fill( + page.getByPlaceholder(m.journey_interlude_placeholder()), + 'Reise nach Wien' + ); + await userEvent.click( + page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) + ); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ note: 'Reise nach Wien' }) + }) + ); + }); + }); + + it('moves keyboard focus into the new row after the interlude is added', async () => { + const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' }; + mockCsrfFetch(() => newItem); + + render(JourneyEditor, defaultProps()); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + await userEvent.fill( + page.getByPlaceholder(m.journey_interlude_placeholder()), + 'Reise nach Wien' + ); + await userEvent.click( + page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) + ); + + await vi.waitFor(() => { + expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy(); + }); + }); +}); + +describe('JourneyEditor — mutation error code routing', () => { + it('shows the specific i18n message when POST /items fails with a backend error code', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' }) + }) + ); + + render(JourneyEditor, defaultProps()); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + await userEvent.fill( + page.getByPlaceholder(m.journey_interlude_placeholder()), + 'Reise nach Wien' + ); + await userEvent.click( + page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) + ); + + await expect + .element(page.getByText(m.error_journey_document_already_added())) + .toBeInTheDocument(); + await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument(); + }); +}); + +describe('JourneyEditor — remove with pending state', () => { + it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => { + const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; + let resolveFetch!: (value: unknown) => void; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve))) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) + ); + + // Row still present, marked as pending (text appears in the row AND the live region, + // so scope the query to the row instead of using a page-wide locator) + await expect.element(page.getByText('Brief A')).toBeInTheDocument(); + await vi.waitFor(() => { + const row = document.querySelector('[data-block-id="i1"]'); + expect(row).toBeTruthy(); + expect(row!.textContent).toContain(m.journey_item_pending_remove()); + expect(row!.className).toContain('opacity-60'); + }); + + resolveFetch({ ok: true }); + await expect.element(page.getByText('Brief A')).not.toBeInTheDocument(); + }); + + it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => { + const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + // Click remove (no note → direct remove) + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) + ); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + await expect.element(page.getByText('Brief A')).toBeInTheDocument(); + }); + + it('removes the row on successful DELETE', async () => { + const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) + ); + + await expect.element(page.getByText('Brief A')).not.toBeInTheDocument(); + }); + + it('focuses a sensible target after a successful remove (not body)', async () => { + const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) + ); + + await expect.element(page.getByText('Brief A')).not.toBeInTheDocument(); + await vi.waitFor(() => { + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true); + }); + }); +}); + +describe('JourneyEditor — reorder via move buttons', () => { + it('move-up calls PUT reorder with swapped IDs', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { id: 'i2', position: 0, document: docSummary('d2', 'Brief B') }, + { id: 'i1', position: 1, document: docSummary('d1', 'Brief A') } + ]) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items/reorder'), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ itemIds: ['i2', 'i1'] }) + }) + ); + }); + }); + + it('move-down calls PUT reorder with swapped IDs', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { id: 'i2', position: 0, document: docSummary('d2', 'Brief B') }, + { id: 'i1', position: 1, document: docSummary('d1', 'Brief A') } + ]) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ })); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items/reorder'), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ itemIds: ['i2', 'i1'] }) + }) + ); + }); + }); + + it('renders the server-confirmed order after a successful reorder', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } + ]; + // Server response deliberately NOT pre-sorted — pins items = updated.sort(...) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { id: 'i1', position: 20, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 10, document: docSummary('d2', 'Brief B') } + ]) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ })); + + await vi.waitFor(() => { + const briefB = page.getByText('Brief B').element(); + const briefA = page.getByText('Brief A').element(); + expect( + briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + }); + + it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + const briefA = page.getByText('Brief A').element(); + const briefB = page.getByText('Brief B').element(); + expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('restores the original DOM order and shows an alert when the reorder request rejects', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } + ]; + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down'))); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + const briefA = page.getByText('Brief A').element(); + const briefB = page.getByText('Brief B').element(); + expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); + +describe('JourneyEditor — live announce region', () => { + it('announces the move only after the reorder resolved, then clears', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { id: 'i2', position: 0, document: docSummary('d2', 'Brief B') }, + { id: 'i1', position: 1, document: docSummary('d1', 'Brief A') } + ]) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + await vi.waitFor(() => { + expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0); + }); + + await vi.waitFor( + () => { + expect((liveRegion?.textContent ?? '').trim()).toBe(''); + }, + { timeout: 2000 } + ); + }); + + it('announces the error text instead of a success message when the move fails', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + await vi.waitFor(() => { + expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload()); + }); + }); +}); + +describe('JourneyEditor — note patch body', () => { + it('sends {"note":null} when note textarea is cleared and blurred', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' } + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi + .fn() + .mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await userEvent.clear(textarea); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items/i1'), + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ note: null }) + }) + ); + }); + }); +}); + +describe('JourneyEditor — duplicate document aria-disabled', () => { + it('already-added document appears as aria-disabled in picker', async () => { + const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] }) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByText(m.journey_add_document())); + await userEvent.fill(page.getByRole('combobox'), 'Karl'); + + // The dropdown item includes the date ("Brief von Karl · …"), the list item does not + await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument(); + const option = page + .getByText(/Brief von Karl ·/) + .element() + .closest('li')!; + expect(option.getAttribute('aria-disabled')).toBe('true'); + }); +}); + +describe('JourneyEditor — unsaved warning banner', () => { + function triggerNavigationAttempt() { + const calls = vi.mocked(beforeNavigate).mock.calls; + if (calls.length === 0) return; + const [callback] = calls[calls.length - 1]; + const cancel = vi.fn(); + (callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({ + cancel, + to: { url: new URL('http://localhost/geschichten') } + }); + return cancel; + } + + it('banner is absent before any edit or navigation attempt', async () => { + render(JourneyEditor, defaultProps()); + expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull(); + }); + + it('banner appears when dirty and a navigation is attempted', async () => { + render(JourneyEditor, defaultProps()); + + // Mark dirty by editing the title + const titleInput = page.getByPlaceholder(/Titel/); + await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); + + // Simulate the user trying to navigate away + const cancel = triggerNavigationAttempt(); + expect(cancel).toHaveBeenCalled(); + + await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument(); + }); + + it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error('server error')); + render( + JourneyEditor, + defaultProps({ + onSubmit, + geschichte: makeGeschichte({ + title: 'Titel', + items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] + }) + }) + ); + + // Mark dirty + const titleInput = page.getByPlaceholder(/Titel/); + await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); + + // Trigger navigation → banner appears + triggerNavigationAttempt(); + await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument(); + + // Attempt save — onSubmit throws + await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() })); + + // Banner must still be visible (isDirty was not cleared) + await vi.waitFor(() => { + expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy(); + }); + }); + + it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => { + // Regression guard for clearOnSuccess(): without it, a curator who edits the + // title and saves successfully stays trapped — the page goto() gets cancelled + // by the still-armed guard and the banner appears after a *successful* save. + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(JourneyEditor, defaultProps({ onSubmit })); + + // Mark dirty + const titleInput = page.getByPlaceholder(/Titel/); + await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); + + // Dirty state blocks navigation + expect(triggerNavigationAttempt()).toHaveBeenCalled(); + + // Save succeeds + await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() })); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled()); + + // Guard is disarmed again — navigation passes and no banner shows + await vi.waitFor(() => { + expect(triggerNavigationAttempt()).not.toHaveBeenCalled(); + }); + expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull(); + }); + + it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => { + mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' })); + render(JourneyEditor, defaultProps()); + + await userEvent.click(page.getByText(m.journey_add_interlude())); + await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext'); + await userEvent.click( + page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) + ); + // The new interlude row renders its note textarea once the POST resolved + await expect + .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) + .toBeInTheDocument(); + + // The item was persisted by its own POST — navigating away loses nothing + expect(triggerNavigationAttempt()).not.toHaveBeenCalled(); + }); +}); + +describe('JourneyEditor — selectedPersons marks dirty', () => { + function getNavCallback() { + const calls = vi.mocked(beforeNavigate).mock.calls; + const [callback] = calls[calls.length - 1]; + return (cancel = vi.fn()) => { + (callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({ + cancel, + to: { url: new URL('http://localhost/geschichten') } + }); + return cancel; + }; + } + + it('removing a person chip marks the editor dirty', async () => { + render( + JourneyEditor, + defaultProps({ + geschichte: makeGeschichte({ + persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }] + }) + }) + ); + + // Confirm navigation is NOT blocked initially (clean state) + const triggerNav = getNavCallback(); + expect(triggerNav()).not.toHaveBeenCalled(); + + // Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen") + await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() })); + + // After person removal, navigation should be blocked + await vi.waitFor(() => { + const cancel = triggerNav(); + expect(cancel).toHaveBeenCalled(); + }); + }); +}); + +describe('JourneyEditor — person chips from GeschichteView', () => { + it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => { + render( + JourneyEditor, + defaultProps({ + geschichte: makeGeschichte({ + persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }] + }) + }) + ); + + await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte b/frontend/src/lib/geschichte/JourneyInterlude.svelte new file mode 100644 index 00000000..dfb66cd7 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte @@ -0,0 +1,18 @@ + + +
+ +

{note}

+
diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts new file mode 100644 index 00000000..506903ec --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; + +const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_interlude?: number; + } +} + +describe('JourneyInterlude', () => { + it('renders the note text as plaintext', async () => { + render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } }); + + await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible(); + }); + + it('has aria-label from i18n (journey_interlude_aria_label)', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`); + expect(el).not.toBeNull(); + }); + + it('has role="note" so the aria-label is announced by screen readers', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + const el = document.querySelector('[role="note"]'); + expect(el).not.toBeNull(); + expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label()); + }); + + it('uses mode-aware journey tokens, not raw orange utilities (#801)', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + const block = document.querySelector('[role="note"]'); + expect(block!.className).toContain('bg-journey-tint'); + expect(block!.className).toContain('border-journey-border'); + expect(block!.className).not.toContain('bg-orange-50'); + }); + + it('note text uses readable body size (text-base, #800)', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + const text = document.querySelector('[role="note"] p'); + expect(text!.className).toContain('text-base'); + expect(text!.className).not.toContain('text-xs'); + }); + + it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { + // Interlude uses Svelte text interpolation ({note}), NOT {@html}. + render(JourneyInterlude, { + props: { note: '' } + }); + + expect(window.__xss_interlude).toBeUndefined(); + expect(document.body.textContent).toContain(' 0); + + +
+
+ +

{doc.title}

+ {#if metaLine} +

{metaLine}

+ {/if} + + + + + + {m.journey_item_open()} + + + + + {#if hasNote} + +
+

{item.note}

+
+ {/if} +
+
diff --git a/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts new file mode 100644 index 00000000..344ad489 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import type { components } from '$lib/generated/api'; + +const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_note?: number; + } +} + +type JourneyItemView = components['schemas']['JourneyItemView']; + +const baseItem = (overrides: Partial = {}): JourneyItemView => ({ + id: 'item1', + position: 0, + document: { + id: 'd1', + title: 'Brief an Helene', + documentDate: '1923-05-15', + datePrecision: 'DAY', + receiverCount: 0 + }, + ...overrides +}); + +describe('JourneyItemCard', () => { + it('renders the document title', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + await expect.element(page.getByText('Brief an Helene')).toBeVisible(); + }); + + it('renders the document date in the meta line when documentDate is present', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + await expect.element(page.getByText(/1923/)).toBeVisible(); + }); + + it('"Brief öffnen" link points to /documents/:id', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = page.getByRole('link', { name: /öffnen/i }); + await expect.element(link).toBeInTheDocument(); + const el = await link.element(); + expect(el.getAttribute('href')).toContain('/documents/d1'); + }); + + it('"Brief öffnen" link meets the 44px touch-target floor', async () => { + // Primary tap action of the phone read path — WCAG 2.5.5 / house rule. + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = page.getByRole('link', { name: /öffnen/i }); + await expect.element(link).toBeInTheDocument(); + const height = link.element().getBoundingClientRect().height; + expect(height).toBeGreaterThanOrEqual(44); + }); + + it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = page.getByRole('link', { name: /1923/i }); + await expect.element(link).toBeInTheDocument(); + }); + + it('"Brief öffnen" link has undated aria-label when documentDate is absent', async () => { + render(JourneyItemCard, { + props: { + item: baseItem({ + document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 } + }) + } + }); + + const link = page.getByRole('link', { name: m.journey_item_open_aria_undated() }); + await expect.element(link).toBeInTheDocument(); + }); + + it('omits date from meta line when documentDate is absent', async () => { + render(JourneyItemCard, { + props: { + item: baseItem({ + document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 } + }) + } + }); + + await expect.element(page.getByText(/1923/)).not.toBeInTheDocument(); + }); + + it('renders sender→receiver in meta line when both are present', async () => { + render(JourneyItemCard, { + props: { + item: baseItem({ + document: { + id: 'd1', + title: 'Brief an Helene', + documentDate: '1923-05-15', + datePrecision: 'DAY', + senderName: 'Franz Raddatz', + receiverName: 'Emma Müller', + receiverCount: 1 + } + }) + } + }); + + await expect.element(page.getByText(/Franz Raddatz/)).toBeVisible(); + await expect.element(page.getByText(/Emma Müller/)).toBeVisible(); + }); + + it('renders note as annotation block when note is present', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } }); + + await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible(); + }); + + it('annotation block uses the brand mint accent border (#798)', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } }); + + const note = document.querySelector('[class*="border-l-2"]'); + expect(note).not.toBeNull(); + expect(note!.className).toContain('border-brand-mint'); + }); + + it('card uses the surface token, not bg-white, so dark mode remaps it', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const card = document.querySelector('[class*="border-line"]'); + expect(card).not.toBeNull(); + expect(card!.className).toContain('bg-surface'); + expect(card!.className).not.toContain('bg-white'); + }); + + it('annotation block is tinted with bg-muted to stand off the white card', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } }); + + const note = document.querySelector('[class*="border-l-2"]'); + expect(note!.className).toContain('bg-muted'); + }); + + it('reading text sizes meet the accessibility floor (#800)', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } }); + + const title = page.getByText('Brief an Helene'); + expect((await title.element()).className).toContain('text-base'); + + const link = await page.getByRole('link', { name: /öffnen/i }).element(); + expect(link.className).toContain('text-sm'); + expect(link.className).not.toContain('text-xs'); + + const noteText = document.querySelector('[class*="border-l-2"] p'); + expect(noteText!.className).toContain('text-base'); + expect(noteText!.className).not.toContain('text-xs'); + }); + + it('omits annotation block when note is blank or whitespace', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } }); + + await expect.element(page.getByText(/ {3}/)).not.toBeInTheDocument(); + }); + + it('omits annotation block when note is absent', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } }); + + const notes = document.querySelectorAll('[class*="border-mint"]'); + expect(notes.length).toBe(0); + }); + + it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { + // Note uses Svelte text interpolation ({item.note}), NOT {@html}. + render(JourneyItemCard, { + props: { + item: baseItem({ + note: '' + }) + } + }); + + expect(window.__xss_note).toBeUndefined(); + expect(document.body.textContent).toContain(' void; + onMoveDown: () => void; + onRemove: () => void; + onNotePatch: (note: string | null) => Promise; +} + +let { + item, + index, + total, + pendingRemove = false, + onMoveUp, + onMoveDown, + onRemove, + onNotePatch +}: Props = $props(); + +const isInterlude = $derived(!item.document); +const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label()); +// Spec LE-2 "Briefmeta": date · von X an Y disambiguates near-identical titles. +const metaLine = $derived(item.document ? formatDocumentMetaLine(item.document) : ''); +const needsConfirmOnRemove = $derived(!!item.note); + +let rootEl: HTMLElement | null = $state(null); +let showNote = $state(!!item.note); +let noteDraft = $state(item.note ?? ''); +let noteSaving = $state(false); +let noteError = $state(''); +let showRemoveConfirm = $state(false); + +async function handleNoteBlur() { + if (noteSaving) return; + const normalizedDraft = noteDraft.trim().length === 0 ? null : noteDraft; + // '' and undefined both mean "no note" — never PATCH a no-op. + if (normalizedDraft === (item.note ?? null)) { + // Opened "Notiz hinzufügen" and blurred without typing → collapse again. + if (!isInterlude && normalizedDraft === null) showNote = false; + return; + } + if (isInterlude && normalizedDraft === null) { + // Interludes must keep a note — restore the draft so the UI doesn't show + // an emptied text that the server still holds. + noteDraft = item.note ?? ''; + return; + } + + noteSaving = true; + noteError = ''; + try { + await onNotePatch(normalizedDraft); + // Clearing an existing note collapses the textarea after the PATCH lands. + if (normalizedDraft === null) showNote = false; + } catch (e) { + noteError = e instanceof Error && e.message ? e.message : m.journey_note_error(); + } finally { + noteSaving = false; + } +} + +async function handleNoteRemove() { + const prevDraft = noteDraft; + const prevShowNote = showNote; + noteDraft = ''; + showNote = false; + noteError = ''; + try { + await onNotePatch(null); + } catch (e) { + noteDraft = prevDraft; + showNote = prevShowNote; + noteError = e instanceof Error && e.message ? e.message : m.journey_note_error(); + } +} + +async function handleNoteOpen() { + showNote = true; + // Spec LE-3: focus moves into the revealed textarea. + await tick(); + rootEl?.querySelector('textarea')?.focus(); +} + +async function handleRemoveClick() { + if (needsConfirmOnRemove) { + showRemoveConfirm = true; + await tick(); + rootEl?.querySelector('[data-remove-confirm-cancel]')?.focus(); + } else { + onRemove(); + } +} + +function handleRemoveConfirm() { + showRemoveConfirm = false; + onRemove(); +} + +async function handleRemoveCancel() { + showRemoveConfirm = false; + await tick(); + rootEl?.querySelector('[data-remove-btn]')?.focus(); +} + + +
+
+ + + + +
+ + +
+ + +
+ {#if isInterlude} + + {m.journey_interlude_label()} + + {:else} + {index + 1}. + {item.document!.title} + {#if metaLine} +

{metaLine}

+ {/if} + {/if} + + {#if showNote} +
+ +
+

{m.journey_note_save_hint()}

+ {#if !isInterlude} + + {/if} +
+ {#if noteError} + + {/if} +
+ {:else if !isInterlude} + + {/if} +
+ + +
+ {#if pendingRemove} + + {m.journey_item_pending_remove()} + + {:else if showRemoveConfirm} +
+ {m.journey_remove_confirm()} + + +
+ {:else} + + {/if} +
+
+
diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts new file mode 100644 index 00000000..b95d429e --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts @@ -0,0 +1,354 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import JourneyItemRow from './JourneyItemRow.svelte'; + +const docItem = (overrides: Partial<{ note: string }> = {}) => ({ + id: 'item-1', + position: 0, + document: { + id: 'doc-1', + title: 'Brief von Karl', + datePrecision: 'DAY' as const, + receiverCount: 0 + }, + ...overrides +}); + +const interludeItem = (note = 'Reise nach Wien') => ({ + id: 'item-2', + position: 1, + note +}); + +const defaultProps = (overrides = {}) => ({ + index: 0, + total: 3, + onMoveUp: vi.fn(), + onMoveDown: vi.fn(), + onRemove: vi.fn(), + onNotePatch: vi.fn().mockResolvedValue(undefined), + ...overrides +}); + +afterEach(() => cleanup()); + +describe('JourneyItemRow — interlude label', () => { + it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps() }); + + await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument(); + await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument(); + }); + + it('uses "Zwischentext" in the move button aria-labels', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) }); + + await expect + .element( + page.getByRole('button', { + name: m.journey_move_up({ title: m.journey_interlude_label() }) + }) + ) + .toBeInTheDocument(); + }); +}); + +describe('JourneyItemRow — note textarea', () => { + it('opens note textarea on "Notiz hinzufügen" click', async () => { + render(JourneyItemRow, { item: docItem(), ...defaultProps() }); + + await userEvent.click(page.getByText(m.journey_note_add())); + + await expect + .element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })) + .toBeInTheDocument(); + }); + + it('blur without typing does not call onNotePatch and collapses the textarea', async () => { + // '' (untouched draft) and undefined (no note) both mean "no note" — a + // spurious PATCH {note: null} must not fire, and the empty textarea closes. + const onNotePatch = vi.fn().mockResolvedValue(undefined); + render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); + + await userEvent.click(page.getByText(m.journey_note_add())); + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + expect(onNotePatch).not.toHaveBeenCalled(); + await expect.element(page.getByText(m.journey_note_add())).toBeInTheDocument(); + }); + + it('moves focus into the textarea when "Notiz hinzufügen" opens it', async () => { + render(JourneyItemRow, { item: docItem(), ...defaultProps() }); + + const toggle = page.getByText(m.journey_note_add()); + expect(toggle.element().getAttribute('aria-expanded')).toBe('false'); + await userEvent.click(toggle); + + await vi.waitFor(() => { + const textarea = page + .getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }) + .element(); + expect(document.activeElement).toBe(textarea); + }); + }); + + it('calls onNotePatch on textarea blur with non-empty value', async () => { + const onNotePatch = vi.fn().mockResolvedValue(undefined); + render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); + + await userEvent.click(page.getByText(m.journey_note_add())); + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); + await userEvent.fill(textarea, 'Eine neue Notiz'); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz'); + }); + + it('limits the note textarea to 2000 characters', async () => { + render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await expect.element(textarea).toHaveAttribute('maxlength', '2000'); + }); +}); + +describe('JourneyItemRow — note error state', () => { + it('shows role=alert error message when onNotePatch rejects', async () => { + const onNotePatch = vi.fn().mockRejectedValue(new Error('server error')); + render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); + + await userEvent.click(page.getByText(m.journey_note_add())); + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); + await userEvent.fill(textarea, 'Eine Notiz'); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + }); +}); + +describe('JourneyItemRow — note remove error state', () => { + it('restores note and shows error when onNotePatch rejects during remove', async () => { + const onNotePatch = vi.fn().mockRejectedValue(new Error('server error')); + render(JourneyItemRow, { + item: docItem({ note: 'keep me' }), + ...defaultProps({ onNotePatch }) + }); + + await userEvent.click(page.getByText(m.journey_note_remove())); + + // textarea should be visible again (showNote restored) + await expect + .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) + .toBeInTheDocument(); + // error alert should be shown + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + }); +}); + +describe('JourneyItemRow — interlude rules', () => { + it('does not show "Notiz entfernen" for interlude items', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps() }); + + // Note section should be visible (interlude always shows note) + await expect + .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) + .toBeInTheDocument(); + // But "Notiz entfernen" must be absent + await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument(); + }); + + it('blocks saving empty text on interlude note blur', async () => { + const onNotePatch = vi.fn().mockResolvedValue(undefined); + render(JourneyItemRow, { + item: interludeItem('original text'), + ...defaultProps({ onNotePatch }) + }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await userEvent.clear(textarea); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + expect(onNotePatch).not.toHaveBeenCalled(); + }); + + it('restores the original note text after a blocked empty-clear blur', async () => { + render(JourneyItemRow, { + item: interludeItem('original text'), + ...defaultProps() + }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await userEvent.clear(textarea); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + await expect.element(textarea).toHaveValue('original text'); + }); +}); + +describe('JourneyItemRow — remove confirm', () => { + it('shows inline confirm when removing a document item that has a note', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps() + }); + + // Click remove (x button) + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ); + + await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: m.journey_remove_confirm_yes() })) + .toBeInTheDocument(); + }); + + it('clicking Bestätigen invokes onRemove (destructive path)', async () => { + const onRemove = vi.fn(); + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps({ onRemove }) + }); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() })); + + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('confirm cancel restores remove button without calling onRemove', async () => { + const onRemove = vi.fn(); + render(JourneyItemRow, { + item: docItem({ note: 'Notiz' }), + ...defaultProps({ onRemove }) + }); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() })); + + expect(onRemove).not.toHaveBeenCalled(); + // The remove button should be back + await expect + .element( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ) + .toBeInTheDocument(); + }); + + it('confirm cancel returns keyboard focus to the row remove button', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Notiz' }), + ...defaultProps() + }); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() })); + + await vi.waitFor(() => { + const removeBtn = page + .getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + .element(); + expect(document.activeElement).toBe(removeBtn); + }); + }); +}); + +describe('JourneyItemRow — remove confirm a11y', () => { + it('confirm area is wrapped in role=group with an accessible label', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps() + }); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ); + + const group = document.querySelector('[role="group"]'); + expect(group).toBeTruthy(); + expect(group!.getAttribute('aria-label')).toBeTruthy(); + }); + + it('keyboard focus moves to Cancel button when confirm appears', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps() + }); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ); + + await vi.waitFor(() => { + const cancelBtn = page + .getByRole('button', { name: m.journey_remove_confirm_cancel() }) + .element(); + expect(document.activeElement).toBe(cancelBtn); + }); + }); + + it('pressing Escape while confirm is open hides confirm and refocuses remove button', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps() + }); + + await userEvent.click( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ); + await vi.waitFor(() => { + const cancelBtn = page + .getByRole('button', { name: m.journey_remove_confirm_cancel() }) + .element(); + expect(document.activeElement).toBe(cancelBtn); + }); + + await userEvent.keyboard('{Escape}'); + + await vi.waitFor(() => { + const removeBtn = page + .getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + .element(); + expect(document.activeElement).toBe(removeBtn); + }); + await expect.element(page.getByText(m.journey_remove_confirm())).not.toBeInTheDocument(); + }); +}); + +describe('JourneyItemRow — pending remove state', () => { + it('renders dimmed with the pending text and without a remove button', async () => { + render(JourneyItemRow, { + item: docItem(), + ...defaultProps({ pendingRemove: true }) + }); + + await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument(); + await expect + .element( + page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) }) + ) + .not.toBeInTheDocument(); + + const root = document.querySelector('[data-block-id="item-1"]')!; + expect(root.className).toContain('opacity-60'); + }); +}); + +describe('JourneyItemRow — drag handle', () => { + it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => { + render(JourneyItemRow, { item: docItem(), ...defaultProps() }); + + const handle = document.querySelector('[data-drag-handle]')!; + expect(handle.getAttribute('tabindex')).toBe('-1'); + expect(handle.getAttribute('aria-hidden')).toBe('true'); + }); +}); diff --git a/frontend/src/lib/geschichte/JourneyReader.svelte b/frontend/src/lib/geschichte/JourneyReader.svelte new file mode 100644 index 00000000..44f11d69 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyReader.svelte @@ -0,0 +1,53 @@ + + +{#if introText} + +

+ {introText} +

+{/if} + +{#if validItems.length === 0} +

+ {m.journey_empty_state()} +

+{:else} +
    + {#each validItems as item (item.id)} +
  1. + {#if item.document != null} + + {:else} + + {/if} +
  2. + {/each} +
+{/if} diff --git a/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts new file mode 100644 index 00000000..a80993f4 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; +import type { components } from '$lib/generated/api'; + +const { default: JourneyReader } = await import('./JourneyReader.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_journey?: number; + } +} + +type GeschichteView = components['schemas']['GeschichteView']; +type JourneyItemView = components['schemas']['JourneyItemView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ + id: 'g1', + title: 'Lesereise Berlin', + body: null as unknown as undefined, + type: 'JOURNEY', + status: 'PUBLISHED', + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides +}); + +const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({ + id, + position, + document: { + id: `d${id}`, + title, + datePrecision: 'DAY', + documentDate: '1923-05-15', + receiverCount: 0 + }, + note +}); + +const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({ + id, + position, + document: undefined, + note +}); + +const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]); + +describe('JourneyReader', () => { + it('renders intro paragraph when body is non-empty', async () => { + render(JourneyReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) } + }); + + await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible(); + }); + + it('intro paragraph uses readable body size (text-lg, #800)', async () => { + render(JourneyReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) } + }); + + const intro = document.querySelector('p'); + expect(intro!.className).toContain('text-lg'); + expect(intro!.className).not.toContain('text-sm'); + }); + + it('omits intro paragraph when body is null', async () => { + render(JourneyReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ body: undefined }) } + }); + + // Only empty state should render + await expect.element(page.getByTestId('journey-empty-state')).toBeVisible(); + }); + + it('omits intro paragraph when body is only whitespace', async () => { + render(JourneyReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ body: ' ' }) } + }); + + // Whitespace-only body must NOT produce a visible intro paragraph. + // The only rendered content should be the empty-state message. + await expect.element(page.getByTestId('journey-empty-state')).toBeVisible(); + const paragraphs = document.querySelectorAll('p:not([data-testid])'); + expect(paragraphs.length).toBe(0); + }); + + it('renders empty-state message when items array is empty', async () => { + render(JourneyReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ items: [] }) } + }); + + await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible(); + }); + + it('renders both intro and empty-state when body is set but items is empty', async () => { + render(JourneyReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ body: 'Eine Einleitung.', items: [] }) + } + }); + + await expect.element(page.getByText('Eine Einleitung.')).toBeVisible(); + await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible(); + }); + + it('renders document items (JourneyItemCard)', async () => { + render(JourneyReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] }) + } + }); + + await expect.element(page.getByText('Brief an Helene')).toBeVisible(); + }); + + it('renders interlude items (JourneyInterlude)', async () => { + render(JourneyReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] }) + } + }); + + await expect.element(page.getByText('Eine Pause.')).toBeVisible(); + }); + + it('omits items where document is null AND note is blank (dangling-item rule)', async () => { + render(JourneyReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + items: [ + { id: 'dangling', position: 0, document: undefined, note: ' ' }, + docItem('item2', 'Echter Brief', 1) + ] + }) + } + }); + + await expect.element(page.getByText('Echter Brief')).toBeVisible(); + // Empty-state must NOT render when valid items exist + await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument(); + }); + + it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => { + // JourneyReader uses Svelte text interpolation, NOT {@html}. + render(JourneyReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + body: '' + }) + } + }); + + expect(window.__xss_journey).toBeUndefined(); + expect(document.body.textContent).toContain(' +import { tick } from 'svelte'; +import type { components } from '$lib/generated/api'; +import { m } from '$lib/paraglide/messages.js'; +import { csrfFetch } from '$lib/shared/cookies'; +import { getErrorMessage } from '$lib/shared/errors'; +import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte'; +import type { DocumentOption } from '$lib/document/documentTypeahead'; + +type JourneyItemView = components['schemas']['JourneyItemView']; + +interface Props { + geschichteId: string; + items?: JourneyItemView[]; +} + +let { geschichteId, items: initialItems = [] }: Props = $props(); + +const uid = $props.id(); +const pickerInputId = `story-doc-picker-${uid}`; + +// Initial-state snapshot — the panel owns the list after mount and updates +// it from API responses; the parent re-mounts to reset (same contract as +// GeschichteEditor/JourneyEditor). +// svelte-ignore state_referenced_locally +let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position)); +let errorMessage = $state(''); +let liveAnnounce = $state(''); +let announceTimer: ReturnType | null = null; +let sectionEl: HTMLElement | null = $state(null); + +$effect(() => () => { + if (announceTimer) clearTimeout(announceTimer); +}); + +const alreadyAddedIds = $derived( + new Set(items.filter((i) => i.document).map((i) => i.document!.id)) +); + +function announce(message: string) { + liveAnnounce = message; + if (announceTimer) clearTimeout(announceTimer); + announceTimer = setTimeout(() => { + liveAnnounce = ''; + announceTimer = null; + }, 500); +} + +function itemTitle(item: JourneyItemView): string { + return item.document?.title ?? m.geschichte_documents_deleted_placeholder(); +} + +/** Maps a failed mutation to a user-facing message — story wording for the + * two journey-flavored 409s, whose generic messages say "Lesereise". */ +async function failureMessage(res: Response): Promise { + const code = (await res.json().catch(() => ({})))?.code; + if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity(); + if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate(); + return code ? getErrorMessage(code) : m.journey_mutation_error_reload(); +} + +/** Pessimistic append — the list updates only with the server's response. */ +async function handleAdd(doc: DocumentOption) { + errorMessage = ''; + try { + const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documentId: doc.id }) + }); + if (!res.ok) { + errorMessage = await failureMessage(res); + return; + } + const newItem: JourneyItemView = await res.json(); + items = [...items, newItem]; + announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) })); + } catch (e) { + console.error('Story document add failed', e); + errorMessage = m.journey_mutation_error_reload(); + } +} + +/** The removed row's button leaves the DOM — without this, focus drops to + * and a keyboard user is teleported to page top. */ +async function moveFocusAfterRemove(removedIdx: number) { + await tick(); + if (items.length === 0) { + sectionEl?.querySelector(`#${pickerInputId}`)?.focus(); + return; + } + const target = items[Math.max(removedIdx - 1, 0)]; + sectionEl + ?.querySelector(`[data-item-id="${CSS.escape(target.id)}"] [data-remove-btn]`) + ?.focus(); +} + +/** Optimistic removal with snapshot-and-rollback. */ +async function handleRemove(item: JourneyItemView) { + const idx = items.findIndex((i) => i.id === item.id); + const prev = [...items]; + errorMessage = ''; + items = items.filter((i) => i.id !== item.id); + await moveFocusAfterRemove(idx); + try { + const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, { + method: 'DELETE' + }); + if (!res.ok) { + items = prev; + await tick(); + sectionEl + ?.querySelector(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`) + ?.focus(); + errorMessage = await failureMessage(res); + return; + } + announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) })); + } catch (e) { + console.error('Story document remove failed', e); + items = prev; + await tick(); + sectionEl + ?.querySelector(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`) + ?.focus(); + errorMessage = m.journey_mutation_error_reload(); + } +} + + + +
{liveAnnounce}
+ +
+ + {m.geschichte_documents_heading()} + +
+ +

{m.geschichte_documents_hint()}

+ + {#if errorMessage} + + {/if} + + {#if items.length === 0} +

{m.geschichte_documents_empty()}

+ {:else} +
    + {#each items as item (item.id)} +
  • + {#if item.document} + + {item.document.title} + + {:else} + + {m.geschichte_documents_deleted_placeholder()} + + {/if} + +
  • + {/each} +
+ {/if} + + + +
+
diff --git a/frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts b/frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts new file mode 100644 index 00000000..db5e91af --- /dev/null +++ b/frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts @@ -0,0 +1,446 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import StoryDocumentPanel from './StoryDocumentPanel.svelte'; + +const docSummary = (id: string, title: string) => ({ + id, + title, + datePrecision: 'DAY' as const, + receiverCount: 0 +}); + +const makeItem = ( + id: string, + position: number, + document?: ReturnType, + note?: string +) => ({ id, position, document, note }); + +/** DocumentListItem fixture as returned by the picker search endpoint. */ +const makeSearchResultItem = (id: string, title: string) => ({ + id, + title, + documentDate: '1880-01-01', + metaDatePrecision: 'DAY', + originalFilename: 'brief.pdf', + receivers: [], + tags: [], + completionPercentage: 0, + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + status: 'UPLOADED', + metadataComplete: false, + scriptType: 'UNKNOWN', + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' +}); + +type MutationResponse = { ok: boolean; status?: number; body?: object }; + +/** + * Routes the picker's GET search to `searchItems` and every mutation + * (POST/DELETE) to `mutation` — the panel talks to both endpoints. + */ +function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? 'GET').toUpperCase(); + if (method === 'GET') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ items: searchItems }) + }); + } + return Promise.resolve({ + ok: mutation.ok, + status: mutation.status ?? (mutation.ok ? 200 : 500), + json: () => Promise.resolve(mutation.body ?? {}) + }); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +const defaultProps = (overrides: Record = {}) => ({ + geschichteId: 'g1', + items: [], + ...overrides +}); + +async function addViaPicker(title: RegExp) { + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await expect.element(page.getByText(title)).toBeInTheDocument(); + await userEvent.click(page.getByText(title)); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('StoryDocumentPanel — rendering', () => { + it('renders linked documents sorted by position', async () => { + render( + StoryDocumentPanel, + defaultProps({ + items: [ + makeItem('i3', 30, docSummary('d3', 'Dritter Brief')), + makeItem('i1', 10, docSummary('d1', 'Erster Brief')) + ] + }) + ); + + const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); + expect(rows[0]).toContain('Erster Brief'); + expect(rows[1]).toContain('Dritter Brief'); + }); + + it('shows the empty state when no items are linked', async () => { + render(StoryDocumentPanel, defaultProps()); + await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument(); + }); + + it('renders a deleted-document item as placeholder row that is still removable', async () => { + render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] })); + + await expect + .element(page.getByText(m.geschichte_documents_deleted_placeholder())) + .toBeInTheDocument(); + await expect + .element( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ + title: m.geschichte_documents_deleted_placeholder() + }) + }) + ) + .toBeInTheDocument(); + }); + + it('wires a visible label to the picker input', async () => { + render(StoryDocumentPanel, defaultProps()); + const input = page.getByRole('combobox').element() as HTMLInputElement; + const label = document.querySelector(`label[for="${input.id}"]`); + expect(label?.textContent).toContain(m.geschichte_documents_picker_label()); + }); +}); + +describe('StoryDocumentPanel — add', () => { + it('POSTs to the items endpoint and appends the created item', async () => { + const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: true, + body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(post?.[0]).toBe('/api/geschichten/g1/items'); + expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' }); + const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); + expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true); + }); + + it('marks an already-linked document as not selectable in the dropdown', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await expect.element(page.getByRole('option')).toBeInTheDocument(); + + const option = document.querySelector('[role="listbox"] [role="option"]'); + expect(option?.getAttribute('aria-disabled')).toBe('true'); + }); + + it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: false, + status: 409, + body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' } + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent(m.geschichte_documents_duplicate()); + }); + + it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: false, + status: 409, + body: { code: 'JOURNEY_AT_CAPACITY' } + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent(m.geschichte_documents_capacity()); + }); + + it('announces a successful add via the polite live region', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: true, + body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion?.textContent).toBe( + m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' }) + ); + }); + + it('routes a 403 response through getErrorMessage on POST', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: false, + status: 403, + body: { code: 'FORBIDDEN' } + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + const alertText = page.getByRole('alert').element().textContent ?? ''; + expect(alertText).not.toBe(''); + expect(alertText).not.toContain('FORBIDDEN'); + }); + + it('shows the generic reload message when POST throws a network error', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]); + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if ((init?.method ?? 'GET').toUpperCase() === 'GET') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ items: [makeSearchResultItem('d1', 'Brief von Eugenie')] }) + }); + } + return Promise.reject(new Error('Network error')); + }) + ); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent(m.journey_mutation_error_reload()); + }); + + it('attaches X-XSRF-TOKEN header from cookie on POST', async () => { + document.cookie = 'XSRF-TOKEN=test-csrf-token'; + const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: true, + body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); + const headers = post?.[1]?.headers as Headers; + expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token'); + document.cookie = 'XSRF-TOKEN=; Max-Age=0'; + }); +}); + +describe('StoryDocumentPanel — remove', () => { + it('DELETEs the item endpoint and removes the row', async () => { + const fetchMock = stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE'); + expect(del?.[0]).toBe('/api/geschichten/g1/items/i1'); + expect(document.querySelectorAll('li').length).toBe(0); + await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument(); + }); + + it('restores the row and shows an error when the DELETE fails', async () => { + stubFetch([], { ok: false, status: 500, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); + expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true); + }); + + it('moves focus to the previous row remove button instead of dropping to body', async () => { + stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ + items: [ + makeItem('i1', 10, docSummary('d1', 'Erster Brief')), + makeItem('i2', 20, docSummary('d2', 'Zweiter Brief')) + ] + }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' }) + }) + ); + + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement?.getAttribute('aria-label')).toBe( + m.geschichte_documents_remove_label({ title: 'Erster Brief' }) + ); + }); + + it('moves focus to the picker input when the last item is removed', async () => { + stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + const input = page.getByRole('combobox').element(); + expect(document.activeElement).toBe(input); + }); + + it('announces a successful remove via the polite live region', async () => { + stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion?.textContent).toBe( + m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' }) + ); + }); + + it('returns focus to the item remove button when DELETE fails with !res.ok', async () => { + stubFetch([], { ok: false, status: 500, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + expect(document.activeElement?.getAttribute('aria-label')).toBe( + m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + ); + }); + + it('returns focus to the item remove button when DELETE throws a network error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('Network error'))) + ); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + expect(document.activeElement?.getAttribute('aria-label')).toBe( + m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + ); + }); + + it('shows the generic reload message when DELETE throws a network error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('Network error'))) + ); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent(m.journey_mutation_error_reload()); + }); + + it('attaches X-XSRF-TOKEN header from cookie on DELETE', async () => { + document.cookie = 'XSRF-TOKEN=test-csrf-token'; + const fetchMock = stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE'); + const headers = del?.[1]?.headers as Headers; + expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token'); + document.cookie = 'XSRF-TOKEN=; Max-Age=0'; + }); +}); diff --git a/frontend/src/lib/geschichte/StoryReader.svelte b/frontend/src/lib/geschichte/StoryReader.svelte new file mode 100644 index 00000000..a6553e47 --- /dev/null +++ b/frontend/src/lib/geschichte/StoryReader.svelte @@ -0,0 +1,123 @@ + + + +
+ + {@html sanitized} +
+ + +{#if g.persons && g.persons.length > 0} +
+

+ {m.geschichten_persons_section()} +

+ +
+{/if} + + +{#if documentItems.length > 0} +
+

+ {m.geschichten_documents_section()} +

+ +
+{/if} diff --git a/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts b/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts new file mode 100644 index 00000000..b3b5eeee --- /dev/null +++ b/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import type { components } from '$lib/generated/api'; + +const { default: StoryReader } = await import('./StoryReader.svelte'); + +afterEach(cleanup); + +type GeschichteView = components['schemas']['GeschichteView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923 fuhr Helene...

', + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides +}); + +describe('StoryReader', () => { + it('renders body HTML content', async () => { + render(StoryReader, { props: { geschichte: baseGeschichte() } }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + }); + + it('omits persons section when persons array is empty', async () => { + render(StoryReader, { props: { geschichte: baseGeschichte({ persons: [] }) } }); + + await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument(); + }); + + it('renders persons section with firstName + lastName joined', async () => { + render(StoryReader, { + props: { + geschichte: baseGeschichte({ + persons: [ + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } + ] + }) + } + }); + + await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible(); + await expect.element(page.getByText('Helene Schmidt')).toBeVisible(); + await expect.element(page.getByText('Karl Müller')).toBeVisible(); + }); + + it('omits documents section when no items have documents', async () => { + render(StoryReader, { props: { geschichte: baseGeschichte({ items: [] }) } }); + + await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument(); + }); + + it('renders document reference cards with title and link for items with documents', async () => { + render(StoryReader, { + props: { + geschichte: baseGeschichte({ + items: [ + { + id: 'i1', + position: 0, + document: { + id: 'd1', + title: 'Brief vom 12. Juli 1938', + documentDate: '1938-07-12', + senderName: 'Franz Raddatz', + receiverName: 'Emma Müller', + datePrecision: 'DAY', + receiverCount: 1 + } as unknown as NonNullable, + note: 'Wichtiger Brief' + } + ] + }) + } + }); + + await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); + await expect.element(page.getByText('Brief vom 12. Juli 1938')).toBeVisible(); + await expect.element(page.getByText(/von Franz Raddatz an Emma Müller/)).toBeVisible(); + await expect.element(page.getByText('Wichtiger Brief')).toBeVisible(); + + const link = document.querySelector('a[href="/documents/d1"]'); + expect(link).not.toBeNull(); + }); + + it('person chip link meets 44px touch-target minimum height', async () => { + render(StoryReader, { + props: { + geschichte: baseGeschichte({ + persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }] + }) + } + }); + + const link = document.querySelector('a[href^="/persons/"]'); + const rect = link?.getBoundingClientRect(); + expect(rect?.height).toBeGreaterThanOrEqual(44); + }); + + it('person chip shows avatar initials', async () => { + render(StoryReader, { + props: { + geschichte: baseGeschichte({ + persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }] + }) + } + }); + + const chip = document.querySelector('a[href="/persons/p1"]'); + expect(chip?.textContent).toContain('HS'); + }); + + it('XSS: Story body is sanitised — injected payload does not execute', async () => { + // StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload. + render(StoryReader, { + props: { + geschichte: baseGeschichte({ + body: '' + }) + } + }); + + expect((window as { __xss_story?: number }).__xss_story).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/geschichte/utils.test.ts b/frontend/src/lib/geschichte/utils.test.ts new file mode 100644 index 00000000..2d5bb6ac --- /dev/null +++ b/frontend/src/lib/geschichte/utils.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './utils'; + +describe('formatAuthorName', () => { + it('joins firstName and lastName with a space', () => { + expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt' })).toBe('Anna Schmidt'); + }); + + it('returns firstName alone when lastName is absent', () => { + expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna'); + }); + + it('returns lastName alone when firstName is absent', () => { + expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt'); + }); + + it('falls back to [Unbekannt] when both names are absent', () => { + expect(formatAuthorName({})).toBe('[Unbekannt]'); + }); + + it('returns empty string for null input', () => { + expect(formatAuthorName(null)).toBe(''); + }); + + it('returns empty string for undefined input', () => { + expect(formatAuthorName(undefined)).toBe(''); + }); +}); + +describe('formatAuthorDisplayName', () => { + it('returns displayName when present', () => { + expect(formatAuthorDisplayName({ displayName: 'Anna Schmidt' })).toBe('Anna Schmidt'); + }); + + it('returns empty string for null input', () => { + expect(formatAuthorDisplayName(null)).toBe(''); + }); + + it('returns empty string for undefined input', () => { + expect(formatAuthorDisplayName(undefined)).toBe(''); + }); +}); + +describe('formatPublishedAt', () => { + it('returns null for null input', () => { + expect(formatPublishedAt(null)).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(formatPublishedAt(undefined)).toBeNull(); + }); + + it('formats an ISO datetime string to a localised date', () => { + const result = formatPublishedAt('2026-04-15T10:00:00Z', 'short'); + expect(result).not.toBeNull(); + expect(result).toContain('2026'); + }); + + it('slices to date-only before formatting (no TZ off-by-one)', () => { + // Both dates should format identically regardless of timezone offset + const a = formatPublishedAt('2026-04-15T00:00:00Z', 'short'); + const b = formatPublishedAt('2026-04-15T23:59:59Z', 'short'); + expect(a).toBe(b); + }); +}); diff --git a/frontend/src/lib/geschichte/utils.ts b/frontend/src/lib/geschichte/utils.ts new file mode 100644 index 00000000..47722729 --- /dev/null +++ b/frontend/src/lib/geschichte/utils.ts @@ -0,0 +1,39 @@ +import { formatDate } from '$lib/shared/utils/date'; +import { m } from '$lib/paraglide/messages.js'; +import { joinNameOrUnknown, unknownPersonName } from '$lib/person/personFormat'; + +type AuthorSummary = { firstName?: string; lastName?: string }; +type DocumentMeta = { documentDate?: string; senderName?: string; receiverName?: string }; +type AuthorView = { displayName: string }; + +export function formatAuthorName(author: AuthorSummary | null | undefined): string { + if (!author) return ''; + // Email is no longer exposed — names or the localized fallback only. + return joinNameOrUnknown(author.firstName, author.lastName); +} + +export function formatAuthorDisplayName(author: AuthorView | null | undefined): string { + if (!author) return ''; + // The server-side fallback is the literal '[Unbekannt]' — localize it here. + return author.displayName === '[Unbekannt]' ? unknownPersonName() : author.displayName; +} + +export function formatPublishedAt( + publishedAt: string | null | undefined, + style: 'short' | 'long' = 'short' +): string | null { + if (!publishedAt) return null; + return formatDate(publishedAt.slice(0, 10), style); +} + +/** "12.07.1938 · von Franz an Emma" — shared by JourneyItemCard and the story doc-reference cards. */ +export function formatDocumentMetaLine(doc: DocumentMeta): string { + const parts: string[] = []; + if (doc.documentDate) parts.push(formatDate(doc.documentDate, 'short')); + if (doc.senderName && doc.receiverName) { + parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName })); + } else if (doc.senderName) { + parts.push(doc.senderName); + } + return parts.join(' · '); +} diff --git a/frontend/src/lib/person/PersonMultiSelect.svelte b/frontend/src/lib/person/PersonMultiSelect.svelte index 41b392a0..1b03c0db 100644 --- a/frontend/src/lib/person/PersonMultiSelect.svelte +++ b/frontend/src/lib/person/PersonMultiSelect.svelte @@ -2,10 +2,11 @@ import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/shared/actions/clickOutside'; +import type { PersonOption } from './personOption'; type Person = components['schemas']['Person']; interface Props { - selectedPersons?: Person[]; + selectedPersons?: PersonOption[]; } let { selectedPersons = $bindable([]) }: Props = $props(); diff --git a/frontend/src/lib/person/personFormat.ts b/frontend/src/lib/person/personFormat.ts index 1ce32e71..37cc7f88 100644 --- a/frontend/src/lib/person/personFormat.ts +++ b/frontend/src/lib/person/personFormat.ts @@ -1,4 +1,5 @@ import { formatDate } from '$lib/shared/utils/date'; +import { m } from '$lib/paraglide/messages.js'; type Person = { firstName?: string | null; lastName: string; displayName: string }; type DocForMeta = { @@ -17,6 +18,19 @@ function djb2(str: string): number { return Math.abs(hash); } +/** Localized fallback when a person has no name parts. */ +export function unknownPersonName(): string { + return m.person_unknown(); +} + +/** + * Single source for the join-names-or-fallback rule. Mirrors the server-side + * fallback in GeschichteService.toView (which emits the literal '[Unbekannt]'). + */ +export function joinNameOrUnknown(firstName?: string | null, lastName?: string | null): string { + return [firstName, lastName].filter(Boolean).join(' ').trim() || unknownPersonName(); +} + export function getInitials(name: string): string { const words = name.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return ''; diff --git a/frontend/src/lib/person/personOption.ts b/frontend/src/lib/person/personOption.ts new file mode 100644 index 00000000..2cf3f427 --- /dev/null +++ b/frontend/src/lib/person/personOption.ts @@ -0,0 +1,27 @@ +import type { components } from '$lib/generated/api'; +import { joinNameOrUnknown } from './personFormat'; + +type Person = components['schemas']['Person']; + +/** + * Narrow chip/dedup contract for person pickers: exactly what PersonMultiSelect + * renders. Full `Person` objects (search results) are structurally assignable; + * view projections without a displayName go through {@link toPersonOption}. + */ +export type PersonOption = Pick; + +/** + * Maps a name-carrying projection (e.g. GeschichteView.PersonView, which has no + * server-computed displayName) into the chip contract. Mirrors the server-side + * fallback in GeschichteService.toView. + */ +export function toPersonOption(p: { + id: string; + firstName?: string | null; + lastName?: string | null; +}): PersonOption { + return { + id: p.id, + displayName: joinNameOrUnknown(p.firstName, p.lastName) + }; +} diff --git a/frontend/src/lib/shared/actions/radioGroupNav.ts b/frontend/src/lib/shared/actions/radioGroupNav.ts index 65721e3a..6c3d5838 100644 --- a/frontend/src/lib/shared/actions/radioGroupNav.ts +++ b/frontend/src/lib/shared/actions/radioGroupNav.ts @@ -18,9 +18,8 @@ export function radioGroupNav( const delta = event.key === 'ArrowRight' ? 1 : -1; const next = (current + delta + radios.length) % radios.length; - radios[current].setAttribute('aria-checked', 'false'); - radios[next].setAttribute('aria-checked', 'true'); radios[next].focus(); + radios.forEach((r, i) => r.setAttribute('aria-checked', i === next ? 'true' : 'false')); onChangeFn?.(radios[next].getAttribute('value') ?? ''); } diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte index 703ad0b3..8b9275b1 100644 --- a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte @@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js'; import { relativeTimeDe } from '$lib/shared/relativeTime'; import type { components } from '$lib/generated/api'; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; interface Props { - drafts: Geschichte[]; + drafts: GeschichteSummary[]; } const { drafts }: Props = $props(); diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts index 0da32564..983764fd 100644 --- a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts @@ -5,24 +5,25 @@ import { page } from 'vitest/browser'; import ReaderDraftsModule from './ReaderDraftsModule.svelte'; import type { components } from '$lib/generated/api'; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; afterEach(() => { cleanup(); }); -const draft1: Geschichte = { +const draft1: GeschichteSummary = { id: 'g1', title: 'Mein erster Entwurf', status: 'DRAFT', - createdAt: '2025-01-01T00:00:00Z', + type: 'STORY', updatedAt: '2025-01-02T00:00:00Z' }; -const draft2: Geschichte = { +const draft2: GeschichteSummary = { id: 'g2', title: 'Zweiter Entwurf', status: 'DRAFT', + type: 'STORY', createdAt: '2025-02-01T00:00:00Z', updatedAt: '2025-02-01T00:00:00Z' }; diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte index 95c446f7..005698a5 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte @@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js'; import { relativeTimeDe } from '$lib/shared/relativeTime'; import type { components } from '$lib/generated/api'; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; interface Props { - stories: Geschichte[]; + stories: GeschichteSummary[]; } const { stories }: Props = $props(); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts index c5d83051..a5a669a8 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts @@ -5,27 +5,28 @@ import { page } from 'vitest/browser'; import ReaderRecentStories from './ReaderRecentStories.svelte'; import type { components } from '$lib/generated/api'; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; afterEach(() => { cleanup(); }); -const story1: Geschichte = { +const story1: GeschichteSummary = { id: 'g1', title: 'Die Familie Müller', body: '

Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.

', status: 'PUBLISHED', - createdAt: '2025-01-01T00:00:00Z', + type: 'STORY', updatedAt: '2025-01-01T00:00:00Z', publishedAt: '2025-01-01T00:00:00Z' }; -const longBodyStory: Geschichte = { +const longBodyStory: GeschichteSummary = { id: 'g2', title: 'Sehr lange Geschichte', body: '

' + 'A'.repeat(200) + '

', status: 'PUBLISHED', + type: 'STORY', createdAt: '2025-02-01T00:00:00Z', updatedAt: '2025-02-01T00:00:00Z', publishedAt: '2025-02-01T00:00:00Z' diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index 6efec2a7..e06f37d4 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -46,6 +46,14 @@ export type ErrorCode = | 'CIRCULAR_RELATIONSHIP' | 'DUPLICATE_RELATIONSHIP' | 'GESCHICHTE_NOT_FOUND' + | 'JOURNEY_ITEM_NOT_FOUND' + | 'JOURNEY_ITEM_POSITION_CONFLICT' + | 'JOURNEY_AT_CAPACITY' + | 'JOURNEY_NOTE_TOO_LONG' + | 'JOURNEY_DOCUMENT_ALREADY_ADDED' + | 'GESCHICHTE_TYPE_IMMUTABLE' + | 'GESCHICHTE_TITLE_TOO_LONG' + | 'GESCHICHTE_INTRO_TOO_LONG' | 'INVALID_CREDENTIALS' | 'SESSION_EXPIRED' | 'MISSING_CREDENTIALS' @@ -164,6 +172,22 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_duplicate_relationship(); case 'GESCHICHTE_NOT_FOUND': return m.error_geschichte_not_found(); + case 'JOURNEY_ITEM_NOT_FOUND': + return m.error_journey_item_not_found(); + case 'JOURNEY_ITEM_POSITION_CONFLICT': + return m.error_journey_item_position_conflict(); + case 'JOURNEY_AT_CAPACITY': + return m.error_journey_at_capacity(); + case 'JOURNEY_NOTE_TOO_LONG': + return m.error_journey_note_too_long(); + case 'JOURNEY_DOCUMENT_ALREADY_ADDED': + return m.error_journey_document_already_added(); + case 'GESCHICHTE_TYPE_IMMUTABLE': + return m.error_geschichte_type_immutable(); + case 'GESCHICHTE_TITLE_TOO_LONG': + return m.error_geschichte_title_too_long(); + case 'GESCHICHTE_INTRO_TOO_LONG': + return m.error_geschichte_intro_too_long(); case 'INVALID_CREDENTIALS': return m.error_invalid_credentials(); case 'SESSION_EXPIRED': diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts similarity index 79% rename from frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts rename to frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts index 2b756e4d..f65f2dd5 100644 --- a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts +++ b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts @@ -1,7 +1,35 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, expectTypeOf } from 'vitest'; import { createBlockDragDrop } from './useBlockDragDrop.svelte'; import type { TranscriptionBlockData } from '$lib/shared/types'; +// --------------------------------------------------------------------------- +// Type-regression guard: createBlockDragDrop must accept any T extends {id: string} +// so JourneyEditor can reuse it without importing TranscriptionBlockData. +// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit +// until the function is made generic. +// --------------------------------------------------------------------------- +describe('createBlockDragDrop — generic type guard', () => { + it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => { + type SimpleItem = { id: string; position: number }; + const items: SimpleItem[] = [ + { id: 'item-1', position: 0 }, + { id: 'item-2', position: 1 } + ]; + const onReorder = vi.fn(); + const dd = createBlockDragDrop({ getSortedBlocks: () => items, onReorder }); + // Verify the hook is functional with the new type — state reads must work + expect(dd.draggedBlockId).toBeNull(); + expect(dd.dragOffsetY).toBe(0); + }); + + it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => { + // If the generic constraint is wrong this line fails tsc --noEmit + expectTypeOf(createBlockDragDrop).toBeFunction(); + // Runtime assertion so browser-mode doesn't report "no assertions" + expect(typeof createBlockDragDrop).toBe('function'); + }); +}); + function makeBlock(id: string, sortOrder: number): TranscriptionBlockData { return { id, diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts similarity index 91% rename from frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts rename to frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts index 900c5d1c..49ac2a69 100644 --- a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts +++ b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts @@ -1,11 +1,12 @@ -import type { TranscriptionBlockData } from '$lib/shared/types'; - -type Options = { - getSortedBlocks: () => TranscriptionBlockData[]; +type Options = { + getSortedBlocks: () => T[]; onReorder: (blockIds: string[]) => void; }; -export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) { +export function createBlockDragDrop({ + getSortedBlocks, + onReorder +}: Options) { let draggedBlockId = $state(null); let dropTargetIdx = $state(null); let dragOffsetY = $state(0); diff --git a/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts b/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts index e2983188..b0b47afa 100644 --- a/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts +++ b/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts @@ -78,6 +78,56 @@ describe('createTypeahead', () => { errorSpy.mockRestore(); }); + it('fetch error sets the error flag so callers can render a failure state', async () => { + const fetchUrl = vi.fn().mockRejectedValue(new Error('500')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ta = createTypeahead({ fetchUrl, debounceMs: 0 }); + expect(ta.error).toBe(false); + ta.setQuery('foo'); + await vi.advanceTimersByTimeAsync(0); + expect(ta.error).toBe(true); + errorSpy.mockRestore(); + }); + + it('error flag clears on the next successful fetch', async () => { + const fetchUrl = vi + .fn() + .mockRejectedValueOnce(new Error('500')) + .mockResolvedValueOnce([{ id: '1' }]); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ta = createTypeahead({ fetchUrl, debounceMs: 0 }); + ta.setQuery('foo'); + await vi.advanceTimersByTimeAsync(0); + expect(ta.error).toBe(true); + ta.setQuery('foob'); + await vi.advanceTimersByTimeAsync(0); + expect(ta.error).toBe(false); + expect(ta.results).toEqual([{ id: '1' }]); + errorSpy.mockRestore(); + }); + + it('sets loading immediately on setQuery so empty results read as pending, not "no results"', async () => { + const fetchUrl = vi.fn().mockResolvedValue([]); + const ta = createTypeahead({ fetchUrl, debounceMs: 300 }); + ta.setQuery('foo'); + // During the debounce window no fetch has run yet — callers must be able to + // distinguish "still searching" from "searched, zero hits". + expect(ta.loading).toBe(true); + await vi.advanceTimersByTimeAsync(300); + expect(ta.loading).toBe(false); + }); + + it('close() cancels the pending debounce — no stale fetch fires, loading resets', async () => { + const fetchUrl = vi.fn().mockResolvedValue([{ id: '1' }]); + const ta = createTypeahead({ fetchUrl, debounceMs: 300 }); + ta.setQuery('foo'); + expect(ta.loading).toBe(true); + ta.close(); + expect(ta.loading).toBe(false); + await vi.advanceTimersByTimeAsync(300); + expect(fetchUrl).not.toHaveBeenCalled(); + }); + it('setActiveIndex updates activeIndex', () => { const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) }); expect(ta.activeIndex).toBe(-1); diff --git a/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts b/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts index f0ac30d7..f970cd29 100644 --- a/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts +++ b/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts @@ -11,6 +11,7 @@ export function createTypeahead(options: Options) { let results: T[] = $state([]); let isOpen = $state(false); let loading = $state(false); + let error = $state(false); let activeIndex = $state(-1); let debounceTimer: ReturnType | undefined; @@ -18,14 +19,18 @@ export function createTypeahead(options: Options) { function setQuery(q: string) { query = q; isOpen = true; + // Set loading before the debounce fires so callers can distinguish + // "still searching" from "searched, zero hits" during the debounce window. + loading = true; clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { - loading = true; + error = false; try { results = await fetchUrl(q); } catch (e) { console.error('typeahead fetch error', e); results = []; + error = true; } finally { loading = false; } @@ -35,6 +40,10 @@ export function createTypeahead(options: Options) { function close() { isOpen = false; activeIndex = -1; + // Cancel the pending debounce — an abandoned query must not fire a stale + // fetch after the dropdown closed, nor leave loading stuck on true. + clearTimeout(debounceTimer); + loading = false; } function setActiveIndex(idx: number) { @@ -65,6 +74,9 @@ export function createTypeahead(options: Options) { get loading() { return loading; }, + get error() { + return error; + }, get activeIndex() { return activeIndex; }, diff --git a/frontend/src/lib/shared/utils/extractText.spec.ts b/frontend/src/lib/shared/utils/extractText.spec.ts index 404ac5cb..89f12985 100644 --- a/frontend/src/lib/shared/utils/extractText.spec.ts +++ b/frontend/src/lib/shared/utils/extractText.spec.ts @@ -48,6 +48,18 @@ describe('extractText', () => { }); }); +// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project. +// The browser project's DOMParser would silently take the safe branch → false green. +// This test fires the regex fallback specifically (Node has no DOMParser). +describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => { + it('does not emit onerror= in output when given an payload (security regression)', () => { + // plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser). + // SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk. + const out = plainExcerpt(''); + expect(out).not.toContain('onerror='); + }); +}); + describe('plainExcerpt', () => { it('returns full text when under the limit', () => { expect(plainExcerpt('

short

', 80)).toBe('short'); diff --git a/frontend/src/lib/shared/utils/extractText.ts b/frontend/src/lib/shared/utils/extractText.ts index 331d9dea..a5ccb976 100644 --- a/frontend/src/lib/shared/utils/extractText.ts +++ b/frontend/src/lib/shared/utils/extractText.ts @@ -1,13 +1,18 @@ /** - * **Not a sanitizer.** This module extracts visible text from a (presumed - * already-sanitised) HTML string for excerpt rendering. It is safe ONLY - * because the Geschichte body is sanitised against the OWASP allow-list - * on the server before persistence, and via DOMPurify on render. + * **Not a sanitizer.** This module extracts visible text from an HTML (or + * plain-text) string for excerpt rendering. The safety invariant is: the + * OUTPUT must only ever be rendered via Svelte text interpolation — never + * `{@html}`. The DOMParser document is inert (scripts don't execute), but + * the returned string is whatever text the input carried. + * + * Note on inputs: STORY bodies are additionally sanitised against the OWASP + * allow-list on the server; JOURNEY intros are stored VERBATIM (unsanitised + * by design — see GeschichteService.bodyForType) and arrive here untrusted. * * Do not use these helpers to defend against XSS — `safeHtml()` in * `./sanitize.ts` is the only sanitiser. Calling `extractText()` on - * untrusted input that has not been sanitised does not protect against - * `javascript:` URLs, event-handler attributes, or `` payloads. + * untrusted input does not protect against `javascript:` URLs, + * event-handler attributes, or `` payloads. */ /** diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 45e4c5f1..f1aea7d8 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -11,7 +11,7 @@ type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; type DocumentListItem = components['schemas']['DocumentListItem']; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; function settled(res: PromiseSettledResult | undefined): T | null { @@ -57,9 +57,9 @@ export async function load({ fetch, parent }) { const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? []; const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); const recentDocs = searchData?.items ?? []; - const recentStories = settled(recentStoriesRes) ?? []; + const recentStories = settled(recentStoriesRes) ?? []; const tagTree = settled(tagTreeRes) ?? []; - const drafts = settled(draftsRes) ?? []; + const drafts = settled(draftsRes) ?? []; return { isReader: true as const, @@ -179,9 +179,9 @@ export async function load({ fetch, parent }) { readerStats: null, topPersons: [] as PersonSummaryDTO[], recentDocs: [] as DocumentListItem[], - recentStories: [] as Geschichte[], + recentStories: [] as GeschichteSummary[], tagTree: [] as TagTreeNodeDTO[], - drafts: [] as Geschichte[], + drafts: [] as GeschichteSummary[], error: 'Daten konnten nicht geladen werden.' as string | null }; } diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 446cd046..db0e1a8e 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -47,6 +47,10 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => { async function openAdvanced() { const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); await filterBtn.click(); + // Wait for slide transition to finish before interacting with contents — + // clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js + // (same guard as the undated-only describe below; this block flaked in CI run 2208). + await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible(); } it('hides AND/OR toggle when fewer than 2 tags are selected', async () => { @@ -132,6 +136,9 @@ describe('SearchFilterBar – undated-only toggle (#668)', () => { async function openAdvanced() { const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); await filterBtn.click(); + // Wait for slide transition to finish before interacting with contents — + // clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js + await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible(); } it('renders the "Nur undatierte" toggle in the advanced row', async () => { diff --git a/frontend/src/routes/geschichten/+page.server.ts b/frontend/src/routes/geschichten/+page.server.ts index 6d802e8e..76828641 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -6,21 +6,27 @@ import type { PageServerLoad } from './$types'; type Person = components['schemas']['Person']; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export const load: PageServerLoad = async ({ url, fetch }) => { const api = createApiClient(fetch); const personIds = url.searchParams.getAll('personId'); - const documentId = url.searchParams.get('documentId') ?? undefined; + const rawDocumentId = url.searchParams.get('documentId'); + const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null; - const [listResult, ...personResults] = await Promise.all([ + const [listResult, docResult, ...personResults] = await Promise.all([ api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', personId: personIds.length ? personIds : undefined, - documentId + documentId: documentId ?? undefined } } }), + documentId + ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) + : Promise.resolve(null), ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } })) ]); @@ -32,9 +38,22 @@ export const load: PageServerLoad = async ({ url, fetch }) => { .filter((r) => r && r.response.ok && r.data) .map((r) => r!.data!) as Person[]; + let documentFilter: { id: string; title: string | null } | null = null; + if (documentId) { + if (docResult && docResult.response.ok && docResult.data) { + const doc = docResult.data; + documentFilter = { + id: documentId, + title: doc.title ?? doc.originalFilename ?? null + }; + } else { + documentFilter = { id: documentId, title: null }; + } + } + return { geschichten: listResult.data ?? [], personFilters, - documentFilter: documentId ?? null + documentFilter }; }; diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index 7ced4ade..d14f950e 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -1,9 +1,9 @@ -
-
-

{m.geschichten_index_title()}

+
+
+

{m.geschichten_index_title()}

{#if data.canBlogWrite} - -
- - - {#each data.personFilters as p (p.id)} + +
+ +
- {/each} - -
- - {#if showPersonPicker} -
- - {#if selectedPersonIds.length > 1} -

- {m.geschichten_filter_and_hint()} -

- {/if} -
- {/if} - - - {#if data.geschichten.length === 0} -
- {#if data.personFilters.length > 0} - {m.geschichten_empty_for_persons({ - names: data.personFilters.map((p) => p.displayName).join(' & ') - })} - {:else} - {m.geschichten_empty_no_filter()} - {/if} -
- {:else} -
- {/if} + + {#if data.documentFilter} + + {/if} + + +
+ + {#if showPersonPicker} +
+ + {#if selectedPersonIds.length > 1} +

+ {m.geschichten_filter_and_hint()} +

+ {/if} +
+ {/if} + + + {#if data.geschichten.length === 0} +
+ {emptyMessage} +
+ {:else} +
    + {#each data.geschichten as g (g.id)} +
  • + + +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/routes/geschichten/DocumentFilterChip.svelte b/frontend/src/routes/geschichten/DocumentFilterChip.svelte new file mode 100644 index 00000000..4a0583b3 --- /dev/null +++ b/frontend/src/routes/geschichten/DocumentFilterChip.svelte @@ -0,0 +1,35 @@ + + +
+ + {m.geschichten_filter_document_chip()} + + + {chipLabel} + + +
diff --git a/frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts b/frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts new file mode 100644 index 00000000..9b5b6576 --- /dev/null +++ b/frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +import DocumentFilterChip from './DocumentFilterChip.svelte'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const VALID_UUID = '11111111-2222-3333-4444-555555555555'; + +describe('DocumentFilterChip', () => { + it('renders the resolved document title inside the chip', async () => { + render(DocumentFilterChip, { + props: { + id: VALID_UUID, + title: 'Brief an Oma', + onremove: vi.fn() + } + }); + + await expect.element(page.getByText(/Brief an Oma/)).toBeVisible(); + }); + + it('renders the prefix label', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible(); + }); + + it('falls back to short UUID when title is null', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: null, onremove: vi.fn() } + }); + + await expect.element(page.getByText(/11111111/)).toBeVisible(); + }); + + it('fires onremove when the remove button is clicked', async () => { + const onremove = vi.fn(); + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove } + }); + + const btn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + btn.click(); + + await vi.waitFor(() => expect(onremove).toHaveBeenCalledOnce()); + }); + + it('remove button aria-label references the resolved title', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + const btn = page.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }); + await expect.element(btn).toBeVisible(); + }); + + it('title= attribute equals the validated id, not a raw query string', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + const chip = document.querySelector('[title]'); + expect(chip?.getAttribute('title')).toBe('Brief an Oma'); + }); + + it('remove button has a minimum 44px touch target', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + const btn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + expect(btn.className).toMatch(/min-h-\[44px\]|min-h-11/); + }); +}); diff --git a/frontend/src/routes/geschichten/[id]/+page.svelte b/frontend/src/routes/geschichten/[id]/+page.svelte index 6a48ec91..fe40ded9 100644 --- a/frontend/src/routes/geschichten/[id]/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/+page.svelte @@ -1,33 +1,35 @@ -
+
-
-
-

- {g.title} -

-

- {authorName()} - {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if} -

-
+
+
+
+ {#if isJourney} + + {m.journey_badge_detail()} + + {/if} +

+ {g.title} +

+
+ {#if authorName} + + {/if} +
+ {#if authorName} +

{authorName}

+ {/if} + {#if publishedAt} +

+ {#if isJourney} + {m.journey_compiled_on({ date: publishedAt })} + {:else} + {m.geschichten_published_on({ date: publishedAt })} + {/if} +

+ {/if} +
+ {#if data.canBlogWrite} +
+ + {m.btn_edit()} + + +
+ {/if} +
+
- -
- - {@html sanitized} + {#if isJourney} + + {:else} + + {/if}
- - - {#if g.persons && g.persons.length > 0} -
-

- {m.geschichten_persons_section()} -

- -
- {/if} - - - {#if g.documents && g.documents.length > 0} -
-

- {m.geschichten_documents_section()} -

- -
- {/if} - - - {#if data.canBlogWrite} -
- - {m.btn_edit()} - - -
- {/if}
diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte index b35f5a35..59f4b8b1 100644 --- a/frontend/src/routes/geschichten/[id]/edit/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/edit/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { m } from '$lib/paraglide/messages.js'; import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte'; +import JourneyEditor from '$lib/geschichte/JourneyEditor.svelte'; import BackButton from '$lib/shared/primitives/BackButton.svelte'; import { getErrorMessage } from '$lib/shared/errors'; import { csrfFetch } from '$lib/shared/cookies'; @@ -12,12 +13,13 @@ let { data }: { data: PageData } = $props(); let submitting = $state(false); let errorMessage: string | null = $state(null); +const isJourney = $derived(data.geschichte.type === 'JOURNEY'); + async function handleSubmit(payload: { title: string; body: string; status: 'DRAFT' | 'PUBLISHED'; personIds: string[]; - documentIds: string[]; }) { submitting = true; errorMessage = null; @@ -30,9 +32,17 @@ async function handleSubmit(payload: { if (!res.ok) { const code = (await res.json().catch(() => ({})))?.code; errorMessage = getErrorMessage(code); - return; + throw new Error('save failed'); } goto(`/geschichten/${data.geschichte.id}`); + } catch (e) { + if (!errorMessage) { + console.error('Geschichte save failed', e); + errorMessage = getErrorMessage(undefined); + } + // Contract: onSubmit rejects on failure — both editors catch and keep + // their dirty state instead of disarming the unsaved-changes guard. + throw e; } finally { submitting = false; } @@ -45,7 +55,8 @@ async function handleSubmit(payload: {

- {m.btn_edit()}: {data.geschichte.title} + {isJourney ? m.journey_edit_title_journey() : m.journey_edit_title_story()}: + {data.geschichte.title}

{#if errorMessage} @@ -57,5 +68,13 @@ async function handleSubmit(payload: {
{/if} - + {#if isJourney} + + {:else} + + {/if}
diff --git a/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts index 5376edb6..82098c52 100644 --- a/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; vi.mock('$app/navigation', () => ({ beforeNavigate: () => {}, @@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte'); afterEach(cleanup); const baseData = (overrides: Record = {}) => ({ + user: undefined, + canWrite: true, + canAnnotate: false, + canBlogWrite: true, geschichte: { id: 'g1', title: 'Die Reise nach Berlin', body: '

Im Jahr 1923...

', status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED', + type: 'STORY' as 'STORY' | 'JOURNEY', persons: [], - documents: [] + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' }, ...overrides }); @@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => { const inputs = document.querySelectorAll('input, textarea, [contenteditable]'); expect(inputs.length).toBeGreaterThan(0); }); + + it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => { + render(GeschichtenEditPage, { + props: { + data: baseData({ + geschichte: { + id: 'g1', + title: 'Die Reise nach Berlin', + body: '', + status: 'DRAFT' as const, + type: 'JOURNEY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + }) + } + }); + + await expect.element(page.getByText(m.journey_add_document())).toBeVisible(); + expect(document.querySelector('[role="toolbar"]')).toBeNull(); + }); + + it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => { + render(GeschichtenEditPage, { + props: { + data: baseData({ + geschichte: { + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923...

', + status: 'DRAFT' as const, + type: 'STORY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + }) + } + }); + + await expect.element(page.getByRole('toolbar')).toBeVisible(); + await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts index 9bc111c2..d06e23f0 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -1,29 +1,58 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ + beforeNavigate: () => {}, + afterNavigate: () => {}, + goto: vi.fn(), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + preloadCode: vi.fn(), + preloadData: vi.fn(), + pushState: vi.fn(), + replaceState: vi.fn(), + disableScrollHandling: vi.fn(), + onNavigate: () => () => {} +})); + +vi.mock('$lib/shared/cookies', () => ({ + csrfFetch: vi.fn() +})); import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; +import { csrfFetch } from '$lib/shared/cookies'; +import { goto } from '$app/navigation'; +import type { components } from '$lib/generated/api'; const { default: GeschichtePage } = await import('./+page.svelte'); afterEach(cleanup); +beforeEach(() => { + vi.clearAllMocks(); +}); -const baseGeschichte = (overrides: Record = {}) => ({ +type GeschichteView = components['schemas']['GeschichteView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ id: 'g1', title: 'Die Reise nach Berlin', body: '

Im Jahr 1923 fuhr Helene...

', - publishedAt: '2026-04-15T10:00:00Z' as string | null, - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as { - firstName?: string; - lastName?: string; - email: string; - } | null, - persons: [] as { id: string; displayName: string }[], - documents: [] as { id: string; title: string; documentDate?: string | null }[], + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + publishedAt: '2026-04-15T10:00:00Z', ...overrides }); const baseData = (overrides: Record = {}) => ({ + user: undefined, + canWrite: false, + canAnnotate: false, geschichte: baseGeschichte(), canBlogWrite: false, ...overrides @@ -41,6 +70,46 @@ describe('geschichten/[id] page', () => { .toBeVisible(); }); + it('spans the directory width with a centered reading column inside the sheet (#799)', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData() } + }); + + const outer = document.querySelector('[class*="mx-auto"]'); + expect(outer!.className).toContain('max-w-7xl'); + + const column = document.querySelector('article [class*="max-w-3xl"]'); + expect(column).not.toBeNull(); + expect(column!.className).toContain('mx-auto'); + }); + + it('renders the article on a reading-sheet surface card (#797)', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData() } + }); + + const article = document.querySelector('article'); + expect(article).not.toBeNull(); + // bg-sheet sits between the sand canvas and the white cards inside the article + for (const cls of ['bg-sheet', 'border-line', 'rounded-sm', 'shadow-sm']) { + expect(article!.className).toContain(cls); + } + }); + + it('journey badge uses mode-aware journey tokens, not raw orange utilities (#801)', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) } + }); + + const badge = document.querySelector('h1')!.parentElement!.querySelector('span'); + expect(badge!.className).toContain('bg-journey-tint'); + expect(badge!.className).toContain('text-journey'); + expect(badge!.className).not.toContain('bg-orange-50'); + }); + it('renders the author full name from firstName + lastName', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), @@ -50,14 +119,12 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); }); - it('falls back to author email when no name is set', async () => { + it('renders the server-computed author displayName verbatim', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ - geschichte: baseGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' } - }) + geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } }) }) } }); @@ -65,10 +132,10 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/fallback@example.com/)).toBeVisible(); }); - it('renders an empty author when author is null', async () => { + it('renders an empty author when author is absent', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) } + props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); @@ -86,7 +153,9 @@ describe('geschichten/[id] page', () => { it('omits the publishedAt suffix when publishedAt is null', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) } + props: { + data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) }) + } }); await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument(); @@ -108,8 +177,8 @@ describe('geschichten/[id] page', () => { data: baseData({ geschichte: baseGeschichte({ persons: [ - { id: 'p1', displayName: 'Helene Schmidt' }, - { id: 'p2', displayName: 'Karl Müller' } + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } ] }) }) @@ -130,13 +199,20 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument(); }); - it('renders the documents section when there are linked documents', async () => { + it('renders the documents section when there are linked journey items', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ geschichte: baseGeschichte({ - documents: [{ id: 'd1', title: 'Brief 1923', documentDate: '1923-04-15' }] + items: [ + { + id: 'item1', + position: 0, + document: { id: 'd1', title: 'Brief 1923', datePrecision: 'DAY', receiverCount: 0 }, + note: 'Brief aus 1923' + } + ] }) }) } @@ -144,6 +220,27 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); await expect.element(page.getByText('Brief 1923')).toBeVisible(); + await expect.element(page.getByText('Brief aus 1923')).toBeVisible(); + expect(document.querySelector('a[href="/documents/d1"]')).not.toBeNull(); + }); + + it('JOURNEY shows "zusammengestellt am" instead of "veröffentlicht am"', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) } + }); + + await expect.element(page.getByText(/zusammengestellt am/i)).toBeVisible(); + await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument(); + }); + + it('renders the author avatar initials in the meta bar', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData() } + }); + + await expect.element(page.getByText('AS', { exact: true })).toBeInTheDocument(); }); it('renders edit and delete actions when canBlogWrite is true', async () => { @@ -167,4 +264,77 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); }); + + it('STORY with items:[] renders rich-text body and no empty-state message', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { + data: baseData({ + geschichte: baseGeschichte({ + type: undefined as unknown as 'STORY' | 'JOURNEY', + items: [] + }) + }) + } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => { + vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 })); + const confirmService = createConfirmService(); + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, confirmService]]), + props: { data: baseData({ canBlogWrite: true }) } + }); + + // Trigger delete — opens confirm dialog + const deleteBtn = page.getByRole('button', { name: /löschen/i }); + await userEvent.click(deleteBtn); + + // Settle the confirmation dialog + confirmService.settle(true); + + // Wait for the async delete to complete, then check goto was called + await vi.waitFor(() => { + expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten'); + }); + }); + + it('delete failure: shows error message when DELETE returns non-ok', async () => { + vi.mocked(csrfFetch).mockResolvedValue( + new Response(JSON.stringify({ code: 'FORBIDDEN' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }) + ); + const confirmService = createConfirmService(); + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, confirmService]]), + props: { data: baseData({ canBlogWrite: true }) } + }); + + // Trigger delete — opens confirm dialog + const deleteBtn = page.getByRole('button', { name: /löschen/i }); + await userEvent.click(deleteBtn); + + // Settle the confirmation dialog + confirmService.settle(true); + + // Wait for the error to appear inline + await expect.element(page.getByRole('alert')).toBeVisible(); + expect(vi.mocked(goto)).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/routes/geschichten/new/+page.server.ts b/frontend/src/routes/geschichten/new/+page.server.ts index 3983d924..652aa86c 100644 --- a/frontend/src/routes/geschichten/new/+page.server.ts +++ b/frontend/src/routes/geschichten/new/+page.server.ts @@ -10,24 +10,21 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => { const api = createApiClient(fetch); const personId = url.searchParams.get('personId'); - const documentId = url.searchParams.get('documentId'); - const [personResult, documentResult] = await Promise.all([ - personId - ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) - : Promise.resolve(null), - documentId - ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) - : Promise.resolve(null) - ]); + const personResult = personId + ? await api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) + : null; // Silently ignore 404/403 to avoid leaking entity existence on unknown IDs. const initialPersons = personResult && personResult.response.ok && personResult.data ? [personResult.data] : []; - const initialDocuments = - documentResult && documentResult.response.ok && documentResult.data - ? [documentResult.data] - : []; - return { initialPersons, initialDocuments }; + // Validate ?type against the known union — prevents unexpected strings from reaching the API. + // Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and + // only the FIRST value is returned by searchParams.get() on repeated params. + const rawType = url.searchParams.get('type'); + const selectedType: 'STORY' | 'JOURNEY' | null = + rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null; + + return { initialPersons, selectedType }; }; diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte index 70ec081e..fe03ae66 100644 --- a/frontend/src/routes/geschichten/new/+page.svelte +++ b/frontend/src/routes/geschichten/new/+page.svelte @@ -1,43 +1,13 @@
@@ -47,19 +17,11 @@ async function handleSubmit(payload: {

{m.geschichten_new_button()}

- {#if errorMessage} - + {#if data.selectedType === 'STORY'} + + {:else if data.selectedType === 'JOURNEY'} + + {:else} + goto(`/geschichten/new?type=${type}`)} /> {/if} - -
diff --git a/frontend/src/routes/geschichten/new/JourneyCreate.svelte b/frontend/src/routes/geschichten/new/JourneyCreate.svelte new file mode 100644 index 00000000..8f71caec --- /dev/null +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte @@ -0,0 +1,90 @@ + + +
+ {#if errorMessage} + + {/if} + +
+ (titleTouched = true)} + placeholder={m.geschichte_editor_title_placeholder()} + aria-label={m.journey_title_aria_label()} + aria-invalid={showTitleError} + class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError + ? 'border-danger' + : 'border-line'}" + /> + {#if showTitleError} +

{m.geschichte_editor_title_required()}

+ {/if} +
+ +
+ + + {m.journey_placeholder_back()} + +
+
diff --git a/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts b/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts new file mode 100644 index 00000000..15c47da9 --- /dev/null +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import { getErrorMessage } from '$lib/shared/errors'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +const { default: JourneyCreate } = await import('./JourneyCreate.svelte'); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('JourneyCreate — failure path', () => { + it('renders the mapped error message when POST /api/geschichten fails with a code', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' }) + }) + ); + + render(JourneyCreate, {}); + + await userEvent.fill( + page.getByRole('textbox', { name: m.journey_title_aria_label() }), + 'Meine Lesereise' + ); + await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() })); + + const alert = page.getByRole('alert'); + await expect.element(alert).toBeInTheDocument(); + await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR')); + }); + + it('navigates to the edit page on success', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ id: 'g-new' }) + }) + ); + + render(JourneyCreate, {}); + + await userEvent.fill( + page.getByRole('textbox', { name: m.journey_title_aria_label() }), + 'Meine Lesereise' + ); + await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() })); + + await vi.waitFor(() => { + expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit'); + }); + }); + + it('shows an error alert when the network request rejects (no crash)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down'))); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(JourneyCreate, {}); + + await userEvent.fill( + page.getByRole('textbox', { name: m.journey_title_aria_label() }), + 'Meine Lesereise' + ); + await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() })); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it('has an accessible label on the title input', async () => { + vi.stubGlobal('fetch', vi.fn()); + render(JourneyCreate, {}); + + await expect + .element(page.getByRole('textbox', { name: m.journey_title_aria_label() })) + .toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/geschichten/new/StoryCreate.svelte b/frontend/src/routes/geschichten/new/StoryCreate.svelte new file mode 100644 index 00000000..58d76083 --- /dev/null +++ b/frontend/src/routes/geschichten/new/StoryCreate.svelte @@ -0,0 +1,58 @@ + + +{#if errorMessage} + +{/if} + + diff --git a/frontend/src/routes/geschichten/new/StoryCreate.svelte.spec.ts b/frontend/src/routes/geschichten/new/StoryCreate.svelte.spec.ts new file mode 100644 index 00000000..9c387cb6 --- /dev/null +++ b/frontend/src/routes/geschichten/new/StoryCreate.svelte.spec.ts @@ -0,0 +1,16 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { m } from '$lib/paraglide/messages.js'; + +vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); +import StoryCreate from './StoryCreate.svelte'; + +afterEach(() => cleanup()); + +describe('StoryCreate — document panel guard (#795)', () => { + it('renders without the document panel — documents attach after the first save', async () => { + render(StoryCreate, { initialPersons: [] }); + + expect(document.body.textContent).not.toContain(m.geschichte_documents_heading()); + }); +}); diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte b/frontend/src/routes/geschichten/new/TypeSelector.svelte new file mode 100644 index 00000000..78c955b4 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte @@ -0,0 +1,97 @@ + + +
+

+ {m.journey_selector_question()} +

+ +
{ + if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType); + }} + > + {#each TYPES as type (type)} + + {/each} +
+ +
{announcement}
+ + {#if !selected} + + {/if} + + +
diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts new file mode 100644 index 00000000..9aff1ca9 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +const { default: TypeSelector } = await import('./TypeSelector.svelte'); + +afterEach(cleanup); + +describe('TypeSelector', () => { + it('renders both type cards', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible(); + await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible(); + }); + + it('radiogroup is correctly labelled', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const group = document.querySelector('[role="radiogroup"]'); + const labelledBy = group?.getAttribute('aria-labelledby'); + const labelEl = labelledBy ? document.getElementById(labelledBy) : null; + expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0); + }); + + it('Weiter button has aria-disabled=true when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('true'); + }); + + it('no card is aria-checked when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true'); + expect(anyChecked).toBe(false); + }); + + it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + expect(radios[0]?.getAttribute('tabindex')).toBe('0'); + expect(radios[1]?.getAttribute('tabindex')).toBe('-1'); + }); + + it('clicking STORY card sets aria-checked=true and enables Weiter', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); + + await expect.element(storyCard).toHaveAttribute('aria-checked', 'true'); + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('false'); + }); + + it('clicking JOURNEY card sets aria-checked=true', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await userEvent.click(journeyCard); + + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + }); + + it('clicking Weiter after selection calls onweiter with the selected type', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + await userEvent.click(page.getByRole('radio', { name: /Geschichte/i })); + const weiter = page.getByRole('button', { name: /Weiter/i }); + await userEvent.click(weiter); + + expect(onweiter).toHaveBeenCalledWith('STORY'); + }); + + it('clicking Weiter without selection does NOT call onweiter', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + // aria-disabled="true" prevents Playwright actionability — dispatch via DOM to test handler behaviour + const weiter = document.querySelector('button[aria-disabled="true"]'); + weiter?.click(); + + expect(onweiter).not.toHaveBeenCalled(); + }); + + it('instructional text is visible when no type is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible(); + }); + + it('ArrowRight moves focus and selection from STORY to JOURNEY', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator + await userEvent.keyboard('{ArrowRight}'); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + await expect.element(storyCard).toHaveAttribute('aria-checked', 'false'); + }); + + it('ArrowLeft wraps from STORY back to JOURNEY', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator + await userEvent.keyboard('{ArrowLeft}'); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/frontend/src/routes/geschichten/new/page.server.test.ts b/frontend/src/routes/geschichten/new/page.server.test.ts new file mode 100644 index 00000000..ba440ca3 --- /dev/null +++ b/frontend/src/routes/geschichten/new/page.server.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://backend:8080' } +})); + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(() => ({ + GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null }) + })) +})); + +import { load } from './+page.server'; + +function makeEvent(search: string, canBlogWrite = true) { + return { + url: new URL(`http://localhost/geschichten/new${search}`), + request: new Request(`http://localhost/geschichten/new${search}`), + fetch: vi.fn(), + parent: vi.fn().mockResolvedValue({ canBlogWrite }) + } as never; +} + +describe('geschichten/new load — selectedType validation (security regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns selectedType: STORY for ?type=STORY', async () => { + const result = await load(makeEvent('?type=STORY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => { + const result = await load(makeEvent('?type=JOURNEY')); + expect(result.selectedType).toBe('JOURNEY'); + }); + + it('returns selectedType: null when ?type param is absent', async () => { + const result = await load(makeEvent('')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for invalid ?type param (security regression)', async () => { + const result = await load(makeEvent('?type=ADMIN')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => { + // Strict equality rejects encoded variants; .includes/.startsWith would not. + const result = await load(makeEvent('?type=STORY%00JOURNEY')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => { + // url.searchParams.get() returns the first value; this is intentional and documented. + const result = await load(makeEvent('?type=STORY&type=JOURNEY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => { + const { createApiClient } = await import('$lib/shared/api.server'); + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } }) + } as never); + + const result = await load(makeEvent('?type=STORY&personId=p1')); + expect(result.selectedType).toBe('STORY'); + expect(result.initialPersons).toHaveLength(1); + }); + + it('redirects non-BLOG_WRITE users to /geschichten', async () => { + await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' }); + }); +}); diff --git a/frontend/src/routes/geschichten/new/page.svelte.test.ts b/frontend/src/routes/geschichten/new/page.svelte.test.ts index 7e10a727..ccf76f8d 100644 --- a/frontend/src/routes/geschichten/new/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/new/page.svelte.test.ts @@ -20,51 +20,87 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte'); afterEach(cleanup); -const baseData = { +const baseData = (overrides: Record = {}) => ({ initialPersons: [] as { id: string; displayName: string }[], - initialDocuments: [] as { id: string; title: string }[] -}; + selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null, + ...overrides +}); describe('geschichten/new page', () => { it('renders the page heading', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); }); it('renders a button (BackButton component)', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); const buttons = document.querySelectorAll('button'); expect(buttons.length).toBeGreaterThan(0); }); it('does not render an error banner by default', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); expect(document.querySelector('[role="alert"]')).toBeNull(); }); - it('renders the GeschichteEditor child component', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + it('renders the GeschichteEditor when selectedType is STORY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } }); // Editor renders inputs/textarea — verify at least one form input is present const inputs = document.querySelectorAll('input, textarea'); expect(inputs.length).toBeGreaterThan(0); }); - it('passes initialPersons and initialDocuments through to the editor', async () => { + it('passes initialPersons through to the editor', async () => { render(GeschichtenNewPage, { props: { - data: { - initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }], - initialDocuments: [{ id: 'd1', title: 'Brief 1923' }] - } + data: baseData({ + selectedType: 'STORY', + initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }] + }) } }); - // Both should appear somewhere in the rendered editor await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); - await expect.element(page.getByText('Brief 1923')).toBeVisible(); + }); + + it('shows TypeSelector radiogroup when selectedType is null', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + await expect.element(page.getByRole('radiogroup')).toBeVisible(); + }); + + it('shows JourneyCreate form when selectedType is JOURNEY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + await expect.element(page.getByRole('button', { name: /Lesereise erstellen/i })).toBeVisible(); + }); + + it('JOURNEY create form offers a return-to-selection link', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + const backLink = page.getByRole('link', { name: /andere Auswahl/i }); + await expect.element(backLink).toBeVisible(); + await expect.element(backLink).toHaveAttribute('href', '/geschichten/new'); + }); + + it('TypeSelector Weiter calls goto with ?type=STORY on STORY selection', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + // Select STORY + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await storyCard.click(); + + // Click Weiter + const weiter = page.getByRole('button', { name: /Weiter/i }); + await weiter.click(); + + expect(goto).toHaveBeenCalledWith('/geschichten/new?type=STORY'); }); }); diff --git a/frontend/src/routes/geschichten/page.server.test.ts b/frontend/src/routes/geschichten/page.server.test.ts new file mode 100644 index 00000000..c9ce8b94 --- /dev/null +++ b/frontend/src/routes/geschichten/page.server.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/shared/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +const VALID_UUID = '11111111-2222-3333-4444-555555555555'; + +function makeUrl(params: Record = {}) { + const url = new URL('http://localhost/geschichten'); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + value.forEach((v) => url.searchParams.append(key, v)); + } else { + url.searchParams.set(key, value); + } + } + return url; +} + +function callLoad(url: URL) { + return load({ + url, + request: new Request('http://localhost/geschichten'), + fetch: vi.fn() as unknown as typeof fetch + }); +} + +function mockApi( + opts: { + listData?: unknown[]; + docOk?: boolean; + docData?: Record | null; + } = {} +) { + const { + listData = [], + docOk = true, + docData = { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } + } = opts; + + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/documents/{id}') { + return Promise.resolve({ + response: { ok: docOk, status: docOk ? 200 : 404 }, + data: docOk ? docData : undefined + }); + } + return Promise.resolve({ + response: { ok: true, status: 200 }, + data: listData + }); + }); + + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + return mockGet; +} + +describe('geschichten page load — documentFilter title resolution', () => { + it('resolves document title when documentId is a valid UUID and document exists', async () => { + mockApi({ docData: { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'Brief an Oma' }); + }); + + it('falls back to originalFilename when document title is null', async () => { + mockApi({ docData: { id: VALID_UUID, title: null, originalFilename: 'scan_001.jpg' } }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'scan_001.jpg' }); + }); + + it('preserves an empty-string title rather than falling back to filename', async () => { + mockApi({ docData: { id: VALID_UUID, title: '', originalFilename: 'scan_001.jpg' } }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: '' }); + }); + + it('degrades to {id, title: null} on 404 without throwing (resolves, never rejects)', async () => { + // Explicit .resolves locks the no-throw guarantee — if error() were called, this would reject + mockApi({ docOk: false }); + + await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({ + documentFilter: { id: VALID_UUID, title: null } + }); + }); + + it('treats 403 identically to 404 — no oracle, loader still resolves', async () => { + // Permanent regression test: loader must not call getErrorMessage/throw on a forbidden title fetch. + // If it did, this assertion would fail with a rejection instead of a resolution. + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/documents/{id}') { + return Promise.resolve({ response: { ok: false, status: 403 }, data: undefined }); + } + return Promise.resolve({ response: { ok: true, status: 200 }, data: [] }); + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({ + documentFilter: { id: VALID_UUID, title: null } + }); + }); + + it('list still populates when title fetch returns 404 (independent results)', async () => { + mockApi({ + listData: [{ id: 'g1', title: 'Some Story' }], + docOk: false + }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.geschichten).toHaveLength(1); + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: null }); + }); + + it('returns null documentFilter when documentId is syntactically invalid', async () => { + mockApi(); + + const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + expect(result.documentFilter).toBeNull(); + }); + + it('does not fetch document title when documentId is invalid', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + expect(mockGet).not.toHaveBeenCalledWith('/api/documents/{id}', expect.anything()); + }); + + it('returns null documentFilter when documentId is absent', async () => { + mockApi(); + + const result = await callLoad(makeUrl()); + + expect(result.documentFilter).toBeNull(); + }); + + it('passes valid documentId to the geschichten API', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(mockGet).toHaveBeenCalledWith( + '/api/geschichten', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ documentId: VALID_UUID }) + }) + }) + ); + }); + + it('omits documentId from the list API when the value is not a valid UUID', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + const listCall = mockGet.mock.calls.find((c) => c[0] === '/api/geschichten'); + expect(listCall?.[1]?.params?.query?.documentId).toBeUndefined(); + }); + + it('keeps forwarding personId filters alongside documentId', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: VALID_UUID, personId: [VALID_UUID] })); + + expect(mockGet).toHaveBeenCalledWith( + '/api/geschichten', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ documentId: VALID_UUID, personId: [VALID_UUID] }) + }) + }) + ); + }); +}); diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index f5f7621e..410b1678 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -33,6 +33,17 @@ function makeData(overrides: Partial = {}): PageData { } as unknown as PageData; } +function makeDocumentFilter(overrides: { id?: string; title?: string | null } = {}): { + id: string; + title: string | null; +} { + return { + id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + title: 'Brief an Oma', + ...overrides + }; +} + describe('geschichten page — multi-person filter chips', () => { it('renders one chip per person in personFilters', async () => { render(Page, { @@ -81,9 +92,12 @@ describe('geschichten page — multi-person filter chips', () => { }) }); - await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click(); + const chipBtn = (await page + .getByRole('button', { name: /Anna A aus Filter entfernen/ }) + .element()) as HTMLElement; + chipBtn.click(); - expect(goto).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce()); const url = vi.mocked(goto).mock.calls[0][0] as string; expect(url).toContain('personId=b'); expect(url).not.toContain('personId=a'); @@ -91,6 +105,19 @@ describe('geschichten page — multi-person filter chips', () => { window.history.replaceState({}, '', originalHref); }); + it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => { + render(Page, { + data: makeData({ + geschichten: [ + { id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' } + ] as PageData['geschichten'] + }) + }); + + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + }); + it('shows the "+ Person wählen" button even when filters are already active', async () => { render(Page, { data: makeData({ @@ -100,6 +127,207 @@ describe('geschichten page — multi-person filter chips', () => { await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible(); }); + describe('document filter chip', () => { + it('renders the document chip when documentFilter is set', async () => { + render(Page, { + data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] }) + }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible(); + await expect.element(page.getByText(/Brief an Oma/)).toBeVisible(); + }); + + it('does not render the document chip when documentFilter is null', async () => { + render(Page, { data: makeData() }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).not.toBeInTheDocument(); + }); + + it('clicking the document chip remove button navigates without documentId', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + window.history.replaceState( + {}, + '', + '/geschichten?documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ); + + render(Page, { + data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] }) + }); + + const removeBtn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + removeBtn.click(); + + await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce()); + const url = vi.mocked(goto).mock.calls[0][0] as string; + expect(url).not.toContain('documentId'); + }); + + it('document chip removal preserves active person filters', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + window.history.replaceState( + {}, + '', + '/geschichten?personId=p1&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ); + + render(Page, { + data: makeData({ + personFilters: [person('p1', 'Anna A')] as PageData['personFilters'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + const removeBtn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + removeBtn.click(); + + await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce()); + const url = vi.mocked(goto).mock.calls[0][0] as string; + expect(url).toContain('personId=p1'); + expect(url).not.toContain('documentId'); + }); + + it('marks the "All" pill as unpressed when document filter is active', async () => { + render(Page, { + data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] }) + }); + + await expect + .element(page.getByRole('button', { name: 'Alle' })) + .toHaveAttribute('aria-pressed', 'false'); + }); + }); + + it('removing a person chip preserves an active document filter in the URL', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + window.history.replaceState( + {}, + '', + '/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ); + + render(Page, { + data: makeData({ + personFilters: [person('a', 'Anna A')] as PageData['personFilters'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + const chipBtn = (await page + .getByRole('button', { name: /Anna A aus Filter entfernen/ }) + .element()) as HTMLElement; + chipBtn.click(); + + await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce()); + const url = vi.mocked(goto).mock.calls[0][0] as string; + expect(url).not.toContain('personId=a'); + expect(url).toContain('documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + + window.history.replaceState({}, '', '/'); + }); + + it('clearAll removes both person and document filters from the URL', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + window.history.replaceState( + {}, + '', + '/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ); + + render(Page, { + data: makeData({ + personFilters: [person('a', 'Anna A')] as PageData['personFilters'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + const allBtn = (await page.getByRole('button', { name: 'Alle' }).element()) as HTMLElement; + allBtn.click(); + + await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce()); + const url = vi.mocked(goto).mock.calls[0][0] as string; + expect(url).not.toContain('personId'); + expect(url).not.toContain('documentId'); + + window.history.replaceState({}, '', '/'); + }); + + describe('empty state precedence', () => { + it('shows geschichten_empty_for_document when only document filter is active', async () => { + render(Page, { + data: makeData({ + geschichten: [], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + await expect.element(page.getByText('Noch keine Geschichten zu diesem Brief')).toBeVisible(); + }); + + it('shows geschichten_empty_for_persons when only person filter is active', async () => { + render(Page, { + data: makeData({ + geschichten: [], + personFilters: [person('a', 'Anna A')] as PageData['personFilters'] + }) + }); + + await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible(); + }); + + it('shows geschichten_empty_no_filter when no filter is active', async () => { + render(Page, { data: makeData({ geschichten: [] }) }); + + await expect + .element(page.getByText('Es gibt noch keine veröffentlichten Geschichten.')) + .toBeVisible(); + }); + + it('person-wins: shows persons message when both person and document filters are active', async () => { + render(Page, { + data: makeData({ + geschichten: [], + personFilters: [person('a', 'Anna A')] as PageData['personFilters'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible(); + await expect + .element(page.getByText('Noch keine Geschichten zu diesem Brief')) + .not.toBeInTheDocument(); + }); + + it('chip renders alongside results (empty state not shown when results exist)', async () => { + render(Page, { + data: makeData({ + geschichten: [ + { id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' } + ] as PageData['geschichten'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible(); + await expect.element(page.getByText(/Lesereise Berlin/)).toBeVisible(); + await expect + .element(page.getByText('Noch keine Geschichten zu diesem Brief')) + .not.toBeInTheDocument(); + }); + }); + it('renders all filter pills with a 44px touch target (h-11)', async () => { render(Page, { data: makeData({ diff --git a/frontend/src/routes/geschichten/page.svelte.test.ts b/frontend/src/routes/geschichten/page.svelte.test.ts index d5e78ba0..8a34427d 100644 --- a/frontend/src/routes/geschichten/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/page.svelte.test.ts @@ -26,7 +26,7 @@ const baseData = (overrides: Record = {}) => ({ title: string; body?: string; publishedAt?: string; - author?: { firstName?: string; lastName?: string; email: string }; + author?: { firstName?: string; lastName?: string }; }>, personFilters: [] as { id?: string; displayName: string }[], documentFilter: null, @@ -35,6 +35,14 @@ const baseData = (overrides: Record = {}) => ({ }); describe('geschichten/+ page', () => { + it('uses the same directory width as Dokumente/Personen overviews (max-w-7xl)', async () => { + render(GeschichtenListPage, { props: { data: baseData() } }); + + const container = document.querySelector('[class*="mx-auto"]'); + expect(container).not.toBeNull(); + expect(container!.className).toContain('max-w-7xl'); + }); + it('renders the page heading', async () => { render(GeschichtenListPage, { props: { data: baseData() } }); @@ -127,7 +135,7 @@ describe('geschichten/+ page', () => { title: 'Reise nach Berlin', body: '

Im Jahr 1923...

', publishedAt: '2026-04-15T10:00:00Z', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } + author: { firstName: 'Anna', lastName: 'Schmidt' } } ] }) @@ -139,7 +147,7 @@ describe('geschichten/+ page', () => { .toBeVisible(); }); - it('authorName falls back to email when first/last names are missing', async () => { + it('authorName falls back to [Unbekannt] when first/last names are missing', async () => { render(GeschichtenListPage, { props: { data: baseData({ @@ -147,14 +155,14 @@ describe('geschichten/+ page', () => { { id: 'g1', title: 'Anonym', - author: { email: 'anon@example.com' } + author: {} } ] }) } }); - expect(document.body.textContent).toContain('anon@example.com'); + expect(document.body.textContent).toContain('[Unbekannt]'); }); it('authorName renders empty when author is undefined', async () => { @@ -178,7 +186,7 @@ describe('geschichten/+ page', () => { { id: 'g1', title: 'Draft', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } + author: { firstName: 'Anna', lastName: 'Schmidt' } } ] }) @@ -188,8 +196,9 @@ describe('geschichten/+ page', () => { // No "·" separator before date when no publishedAt const titleHeading = document.querySelector('h2'); const card = titleHeading?.closest('li'); - // The middle paragraph (author line) should not contain "·" expect(card?.textContent).toContain('Anna Schmidt'); + // "·" separator must be absent when there is no publishedAt date + expect(card?.textContent).not.toContain('·'); }); it('omits the body excerpt when body is empty', async () => { @@ -201,7 +210,7 @@ describe('geschichten/+ page', () => { id: 'g1', title: 'No Body', body: '', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } + author: { firstName: 'Anna', lastName: 'Schmidt' } } ] }) diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 66702879..2d2c08cd 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -34,6 +34,9 @@ --color-overlay: var(--c-overlay); --color-muted: var(--c-muted); + /* Reading sheet — article panel between canvas and the white cards it contains */ + --color-sheet: var(--c-sheet); + /* Borders */ --color-line: var(--c-line); --color-line-2: var(--c-line-2); @@ -77,6 +80,21 @@ --color-warning: #b45309; --color-warning-fg: #ffffff; + /* Warning surface — amber banner (bg/border/text), mode-aware */ + --color-warning-bg: var(--c-warning-bg); + --color-warning-border: var(--c-warning-border); + --color-warning-text: var(--c-warning-text); + + /* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */ + --color-journey-tint: var(--c-journey-bg); + --color-journey: var(--c-journey-text); + --color-journey-border: var(--c-journey-border); + + /* Interlude row — neutral surface with left accent border; ZWISCHENTEXT label */ + --color-interlude-bg: var(--c-interlude-bg); + --color-interlude-border: var(--c-interlude-border); + --color-interlude-label: var(--c-interlude-label); + /* Static brand tokens (not themed) */ --color-brand-navy: var(--palette-navy); --color-brand-mint: var(--palette-mint); @@ -91,6 +109,7 @@ --c-surface: #ffffff; --c-overlay: #ffffff; --c-muted: #f5f4ef; + --c-sheet: #fafaf7; /* between canvas and surface — spec .g-article value */ --c-line: #e4e2d7; --c-line-2: #eeede8; @@ -128,6 +147,22 @@ /* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */ --c-parchment: #faf8f1; + /* Journey / Lesereise — orange semantic tokens + Text #7A3F0E on bg #FEF0E6 ≈ 7.4:1 — WCAG AAA ✓ (text-xs requires 4.5:1 normal-text) */ + --c-journey-bg: #fef0e6; + --c-journey-text: #7a3f0e; + --c-journey-border: #f0c99a; + + /* Interlude (Zwischentext) — neutral warm surface with left accent border */ + --c-interlude-bg: #f5f4f0; + --c-interlude-border: #a1dcd8; + --c-interlude-label: #4b5563; + + /* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */ + --c-warning-bg: #fffbeb; + --c-warning-border: #fcd34d; + --c-warning-text: #92400e; + /* Tag color tokens — decorative dot colors on tag chips */ --c-tag-sage: #5a8a6a; --c-tag-sienna: #a0522d; @@ -182,6 +217,7 @@ --c-surface: #011526; --c-overlay: #011e38; --c-muted: #011a30; + --c-sheet: #011222; /* between canvas and surface */ --c-line: #0d3358; --c-line-2: #092843; @@ -246,6 +282,23 @@ /* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for visibility parity with the 8% light-mode token. Decorative carve-out. */ --c-gutter-stripe: rgba(161, 220, 216, 0.14); + + /* Journey / Lesereise — muted warm tint on dark navy; text #E8862A on + #3A2A1A ≈ 5.2:1 — WCAG AA ✓ (text-xs requires 4.5:1 normal-text) */ + --c-journey-bg: #3a2a1a; + --c-journey-text: #e8862a; + --c-journey-border: #7a4a1e; + + /* Interlude (Zwischentext) — KEEP IN SYNC with :root[data-theme='dark'] */ + --c-interlude-bg: #151c22; + --c-interlude-border: #00c7b1; + --c-interlude-label: #8b97a5; + + /* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓ + KEEP IN SYNC with :root[data-theme='dark'] */ + --c-warning-bg: #2a2113; + --c-warning-border: #6d5417; + --c-warning-text: #fbd38d; } } @@ -258,6 +311,7 @@ --c-surface: #011526; --c-overlay: #011e38; --c-muted: #011a30; + --c-sheet: #011222; /* between canvas and surface */ --c-line: #0d3358; --c-line-2: #092843; @@ -321,6 +375,21 @@ /* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */ --c-gutter-stripe: rgba(161, 220, 216, 0.14); + + /* Journey / Lesereise — KEEP IN SYNC with the @media block above */ + --c-journey-bg: #3a2a1a; + --c-journey-text: #e8862a; + --c-journey-border: #7a4a1e; + + /* Interlude (Zwischentext) — KEEP IN SYNC with the @media block above */ + --c-interlude-bg: #151c22; + --c-interlude-border: #00c7b1; + --c-interlude-label: #8b97a5; + + /* Warning surface — KEEP IN SYNC with the @media block above */ + --c-warning-bg: #2a2113; + --c-warning-border: #6d5417; + --c-warning-text: #fbd38d; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */