diff --git a/CLAUDE.md b/CLAUDE.md index bba08400..552aeefc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,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. @@ -163,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` (journey/geschichte domain constraints). ### Security / Permissions @@ -271,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` (journey/geschichte domain constraints). --- 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 d4715f40..011ab3b1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -851,14 +851,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()); 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 1eaf8a2b..ba8eac3b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -128,8 +128,14 @@ public enum ErrorCode { 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 Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */ GESCHICHTE_TYPE_MISMATCH, + /** 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, // --- Tags --- /** A tag with the given ID does not exist. 404 */ 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 4eed4e16..30b6af59 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java @@ -37,10 +37,12 @@ public class GeschichteController { public List list( @RequestParam(required = false) GeschichteStatus status, @RequestParam(name = "personId", required = false) List personIds, + @RequestParam(required = false) UUID documentId, @RequestParam(required = false, defaultValue = "50") int limit) { return geschichteService.list( status, personIds == null ? List.of() : personIds, + documentId, limit); } @@ -51,14 +53,14 @@ public class GeschichteController { @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); } 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 f0947218..6fe40a8e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java @@ -25,7 +25,7 @@ public interface GeschichteRepository extends JpaRepository, J */ @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.body AS body + 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) @@ -33,11 +33,15 @@ public interface GeschichteRepository extends JpaRepository, J (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("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 2a0d8819..9df6c131 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -105,7 +105,7 @@ public class GeschichteService { *

Returns a {@link GeschichteSummary} projection — never carries items, preventing * LazyInitializationException on the non-transactional list path. */ - public List list(GeschichteStatus status, List personIds, 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); @@ -119,7 +119,7 @@ public class GeschichteService { long personCount = (personIds == null) ? 0 : personIds.size(); return geschichteRepository - .findSummaries(effective, authorId, safePersonIds, personCount) + .findSummaries(effective, authorId, safePersonIds, personCount, documentId) .stream() .limit(safeLimit) .toList(); @@ -127,13 +127,19 @@ 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())) .build(); @@ -141,20 +147,28 @@ public class GeschichteService { 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())); @@ -162,7 +176,8 @@ public class GeschichteService { 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 @@ -192,6 +207,16 @@ public class GeschichteService { } } + /** + * 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) { + return type == GeschichteType.JOURNEY ? body : sanitize(body); + } + private String sanitize(String body) { if (body == null) return null; return BODY_SANITIZER.sanitize(body); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java index ae9d0de7..d8fb9be1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java @@ -31,12 +31,15 @@ public interface GeschichteSummary { 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(); - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - String getEmail(); } } 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 969ca6dd..c2b78597 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java @@ -15,5 +15,6 @@ public class GeschichteUpdateDTO { private String title; private String body; private GeschichteStatus status; + private GeschichteType type; private List personIds; } 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 index a1b3baee..66b9ab27 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -30,6 +30,19 @@ public interface JourneyItemRepository extends JpaRepository /** 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); + /** * 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() 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 index f189a9d3..c06fb72c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -18,6 +18,7 @@ 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; @@ -30,7 +31,8 @@ public class JourneyItemService { static final int MAX_ITEMS = 100; static final int POSITION_STEP = 10; - static final int MAX_NOTE_LENGTH = 5000; + // 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; @@ -63,12 +65,16 @@ public class JourneyItemService { } if (note != null && note.length() > MAX_NOTE_LENGTH) { - throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + 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()); } @@ -82,7 +88,16 @@ public class JourneyItemService { .document(doc) .note(note) .build(); - JourneyItem saved = journeyItemRepository.save(item); + // 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) { + 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, @@ -106,7 +121,7 @@ public class JourneyItemService { String note = normalizeNote(noteField.orElse(null)); if (note != null && note.length() > MAX_NOTE_LENGTH) { - throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG, "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters"); } 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..5939d2c6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V74__journey_items_document_dedup_and_note_length.sql @@ -0,0 +1,21 @@ +-- 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). + +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/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..65fbb21f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -2166,7 +2166,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 +2202,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/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index 9c7bf25c..caf1a515 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -63,7 +63,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void list_returns200_forReader() throws Exception { - when(geschichteService.list(any(), any(), anyInt())) + when(geschichteService.list(any(), any(), any(), anyInt())) .thenReturn(List.of(summaryStub("Story A"))); mockMvc.perform(get("/api/geschichten")) @@ -75,13 +75,13 @@ class GeschichteControllerTest { @WithMockUser(authorities = "READ_ALL") void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception { UUID personId = UUID.randomUUID(); - when(geschichteService.list(any(), eq(List.of(personId)), anyInt())) + when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt())) .thenReturn(List.of()); mockMvc.perform(get("/api/geschichten").param("personId", personId.toString())) .andExpect(status().isOk()); - verify(geschichteService).list(any(), eq(List.of(personId)), anyInt()); + verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt()); } @Test @@ -89,7 +89,7 @@ class GeschichteControllerTest { void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception { UUID a = UUID.randomUUID(); UUID b = UUID.randomUUID(); - when(geschichteService.list(any(), eq(List.of(a, b)), anyInt())) + when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt())) .thenReturn(List.of()); mockMvc.perform(get("/api/geschichten") @@ -97,7 +97,7 @@ class GeschichteControllerTest { .param("personId", b.toString())) .andExpect(status().isOk()); - verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt()); + verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt()); } // ─── GET /api/geschichten/{id} ─────────────────────────────────────────── @@ -150,7 +150,7 @@ class GeschichteControllerTest { void create_returns201_withBlogWrite() throws Exception { UUID id = UUID.randomUUID(); when(geschichteService.create(any(GeschichteUpdateDTO.class))) - .thenReturn(draft(id, "New")); + .thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT)); GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); dto.setTitle("New"); @@ -178,7 +178,7 @@ class GeschichteControllerTest { void update_returns200_withBlogWrite() throws Exception { UUID id = UUID.randomUUID(); when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) - .thenReturn(published(id, "Updated")); + .thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED)); mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -381,35 +381,13 @@ class GeschichteControllerTest { return new JourneyItemView(id, position, null, note); } - 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<>()) - .items(new ArrayList<>()) - .build(); - } - - 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<>()) - .items(new ArrayList<>()) - .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

", - GeschichteStatus.PUBLISHED, GeschichteType.STORY, + status, GeschichteType.STORY, null, new HashSet<>(), List.of(), LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); } @@ -423,6 +401,7 @@ class GeschichteControllerTest { 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 index 025f5296..3c324b32 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java @@ -6,6 +6,8 @@ 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; @@ -16,6 +18,7 @@ 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; @@ -28,6 +31,7 @@ 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; @@ -49,6 +53,7 @@ class GeschichteHttpTest { @Autowired GeschichteRepository geschichteRepository; @Autowired AppUserRepository appUserRepository; + @Autowired UserGroupRepository userGroupRepository; @Autowired PasswordEncoder passwordEncoder; private RestTemplate http; @@ -63,6 +68,8 @@ class GeschichteHttpTest { 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)) @@ -184,15 +191,78 @@ class GeschichteHttpTest { 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\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}"; + String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}"; ResponseEntity resp = http.postForEntity( baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class); return extractFaSessionCookie(resp); @@ -215,7 +285,8 @@ class GeschichteHttpTest { } private RestTemplate noThrowRestTemplate() { - RestTemplate template = new RestTemplate(); + // 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 { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java index ec0d79a5..49c31bf8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java @@ -4,6 +4,11 @@ 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; @@ -27,6 +32,8 @@ class GeschichteListProjectionTest { @Autowired GeschichteRepository geschichteRepository; @Autowired AppUserRepository appUserRepository; @Autowired PersonRepository personRepository; + @Autowired DocumentRepository documentRepository; + @Autowired JourneyItemRepository journeyItemRepository; AppUser author; AppUser otherAuthor; @@ -48,18 +55,31 @@ class GeschichteListProjectionTest { geschichteRepository.save(draft("Entwurf", author)); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + 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); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).isEmpty(); } @@ -67,17 +87,24 @@ class GeschichteListProjectionTest { // ─── AuthorSummary nested projection ───────────────────────────────────── @Test - void findSummaries_exposes_nested_author_firstName_lastName_email() { + 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); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).hasSize(1); GeschichteSummary.AuthorSummary a = result.get(0).getAuthor(); - assertThat(a.getEmail()).isEqualTo("franz@raddatz.de"); + 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 ──────────────────────────────────────────── @@ -94,7 +121,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(journey); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).hasSize(1); assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY); @@ -108,7 +135,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(draft("Fremder Entwurf", otherAuthor)); List result = geschichteRepository.findSummaries( - GeschichteStatus.DRAFT, author.getId(), sentinel(), 0); + GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null); assertThat(result).hasSize(1); assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf"); @@ -122,7 +149,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(published("B", author)); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).hasSize(2); } @@ -143,7 +170,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(withAnna); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1); + GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null); assertThat(result).hasSize(1); assertThat(result.get(0).getTitle()).isEqualTo("Franz story"); @@ -164,12 +191,41 @@ class GeschichteListProjectionTest { geschichteRepository.save(onlyFranz); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2); + 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) { @@ -189,6 +245,16 @@ class GeschichteListProjectionTest { .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/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index 11db3c1a..5cef25e0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -80,20 +80,20 @@ 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..c55f6cc0 --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte @@ -0,0 +1,144 @@ + + +
picker.close()} class="relative"> + + + {#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)} + onkeydown={(e) => handleOptionKeydown(e, doc)} + tabindex={disabled ? -1 : 0} + class={[ + 'px-3 py-2 text-ink select-none', + i === picker.activeIndex ? 'bg-muted' : '', + disabled + ? 'cursor-default opacity-50' + : 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none' + ].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..046d5767 --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts @@ -0,0 +1,200 @@ +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(); + }); +}); 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 ceb29071..5b49d7d7 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -728,6 +728,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/backfill-titles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillTitles"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/backfill-file-hashes": { parameters: { query?: never; @@ -1464,22 +1480,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/conversation": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getConversation"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/dashboard/resume": { parameters: { query?: never; @@ -1836,6 +1836,7 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; + hasTranscription: boolean; thumbnailUrl?: string; }; PersonMention: { @@ -2023,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 */ @@ -2311,6 +2331,11 @@ export interface components { color?: string; /** Format: int32 */ documentCount: number; + /** + * Format: int32 + * @description Distinct documents tagged with this tag or any descendant tag (subtree rollup) + */ + subtreeDocumentCount: number; children?: components["schemas"]["TagTreeNodeDTO"][]; /** * Format: uuid @@ -2486,7 +2511,6 @@ export interface components { AuthorSummary: { firstName?: string; lastName?: string; - email: string; }; GeschichteSummary: { body?: string; @@ -2497,40 +2521,12 @@ export interface components { type: "STORY" | "JOURNEY"; /** @enum {string} */ status: "DRAFT" | "PUBLISHED"; + /** Format: date-time */ + updatedAt: string; author?: components["schemas"]["AuthorSummary"]; /** Format: date-time */ publishedAt?: string; }; - AuthorView: { - /** Format: uuid */ - id: string; - displayName: string; - }; - GeschichteView: { - /** Format: uuid */ - id: string; - title: string; - body?: string; - /** @enum {string} */ - status: "DRAFT" | "PUBLISHED"; - /** @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; - }; - PersonView: { - /** Format: uuid */ - id: string; - firstName?: string; - lastName?: string; - }; DocumentVersionSummary: { /** Format: uuid */ id: string; @@ -3733,7 +3729,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"][]; + "*/*": components["schemas"]["GeschichteSummary"][]; }; }; }; @@ -3757,7 +3753,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"]; + "*/*": components["schemas"]["GeschichteView"]; }; }; }; @@ -4286,6 +4282,26 @@ export interface operations { }; }; }; + backfillTitles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BackfillResult"]; + }; + }; + }; + }; backfillFileHashes: { parameters: { query?: never; @@ -4485,7 +4501,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"]; + "*/*": components["schemas"]["GeschichteView"]; }; }; }; @@ -5476,32 +5492,6 @@ export interface operations { }; }; }; - getConversation: { - parameters: { - query: { - senderId: string; - receiverId?: string; - from?: string; - to?: string; - dir?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["Document"][]; - }; - }; - }; - }; getResume: { parameters: { query?: never; diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte b/frontend/src/lib/geschichte/GeschichteEditor.svelte index 1448b73c..b3b5a9b6 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte @@ -5,13 +5,14 @@ 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 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']; interface Props { - geschichte?: Geschichte | null; + geschichte?: GeschichteView | null; initialPersons?: Person[]; onSubmit: (payload: { title: string; @@ -31,8 +32,8 @@ let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Pr 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 selectedPersons: PersonOption[] = $state( + geschichte?.persons ? Array.from(geschichte.persons).map(toPersonOption) : initialPersons ); let dirty = $state(false); @@ -227,35 +228,7 @@ function exec(action: () => void) {
- +
diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts index c0aa748d..b06f5ae8 100644 --- a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts @@ -12,7 +12,7 @@ const baseRow = (overrides = {}) => ({ body: '

Im Jahr 1923...

', type: 'STORY' as 'STORY' | 'JOURNEY', status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }, + author: { firstName: 'Anna', lastName: 'Schmidt' }, publishedAt: '2026-04-15T10:00:00Z', ...overrides }); diff --git a/frontend/src/lib/geschichte/GeschichteSidebar.svelte b/frontend/src/lib/geschichte/GeschichteSidebar.svelte new file mode 100644 index 00000000..83f69f76 --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteSidebar.svelte @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte b/frontend/src/lib/geschichte/GeschichtenCard.svelte index f7f71166..353909fe 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte @@ -27,7 +27,7 @@ function authorName(g: GeschichteSummary): string { const a = g.author; if (!a) return ''; const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim(); - return full || a.email || ''; + return full || '[Unbekannt]'; } diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts index 2aeefc5a..fd1ddcbc 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts @@ -16,7 +16,6 @@ const makeStory = (id: string, title: string, body: string | undefined = '

Bod items: [], author: { id: 'u1', - email: 'marcel@example.com', firstName: 'Marcel', lastName: 'Raddatz', enabled: true, diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts index 9b46a7d8..34611cd3 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts @@ -17,7 +17,6 @@ const makeGeschichte = (overrides: Record = {}): GeschichteSumm type: 'STORY' as const, publishedAt: '2026-04-15T10:00:00Z', author: { - email: 'a@b', firstName: 'Anna', lastName: 'Schmidt' }, @@ -103,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..c096e669 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte @@ -0,0 +1,105 @@ + + +

+
+ + +
+ + {#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..5c418a1e --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -0,0 +1,394 @@ + + + +
{liveAnnounce}
+ +
+ +
+ +
+ 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 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} + + + +
dragDrop.handlePointerMove(e)} + onpointerup={() => dragDrop.handlePointerUp()} + class="flex flex-col gap-2" + > + {#if items.length === 0} +

{m.journey_empty_state()}

+ {/if} + {#each items as item, i (item.id)} + + +
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)} + /> +
+ {/each} +
+ + +
+ + + +
+ + +
+

+ {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..db181eca --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -0,0 +1,628 @@ +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'; + +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('item-add enables publish button (isDirty stays false, 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() })); + + // 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() })); + + 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() })); + + 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() })); + + 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('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 — 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/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte new file mode 100644 index 00000000..d1016b83 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte @@ -0,0 +1,249 @@ + + +
+
+ + + + +
+ + +
+ + +
+ {#if isInterlude} + + {m.journey_interlude_label()} + + {:else} + {index + 1}. + {item.document!.title} + {/if} +
+ + +
+ {#if pendingRemove} + + {m.journey_item_pending_remove()} + + {:else if showRemoveConfirm} +
+ {m.journey_remove_confirm()} + + +
+ {:else} + + {/if} +
+
+ + + {#if showNote} +
+ +
+

{m.journey_note_save_hint()}

+ {#if !isInterlude} + + {/if} +
+ {#if noteError} + + {/if} +
+ {:else if !isInterlude} +
+ +
+ {/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..685f505f --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts @@ -0,0 +1,249 @@ +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('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() })); + + 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() })); + 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() })); + 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() })) + .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() })); + 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() }).element(); + expect(document.activeElement).toBe(removeBtn); + }); + }); +}); + +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() })) + .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/README.md b/frontend/src/lib/geschichte/README.md index 49024cfc..f1e6e959 100644 --- a/frontend/src/lib/geschichte/README.md +++ b/frontend/src/lib/geschichte/README.md @@ -4,7 +4,7 @@ UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-te ## What this domain owns -Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`. +Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`. Utilities: `utils.ts`. ## What this domain does NOT own @@ -15,15 +15,19 @@ Utilities: `utils.ts`. ## Key components -| Component | Used in | Notes | -| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds | -| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page | -| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) | -| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions | -| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude | -| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `` for a document item; dated/undated aria-label, ✎ annotation glyph | -| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` | +| Component | Used in | Notes | +| -------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` | +| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `
` mobile collapsibles with 44px touch targets | +| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish | +| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm | +| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form | +| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page | +| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) | +| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions | +| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude | +| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `` for a document item; dated/undated aria-label, ✎ annotation glyph | +| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` | ## utils.ts diff --git a/frontend/src/lib/geschichte/utils.test.ts b/frontend/src/lib/geschichte/utils.test.ts index e5f78253..2d5bb6ac 100644 --- a/frontend/src/lib/geschichte/utils.test.ts +++ b/frontend/src/lib/geschichte/utils.test.ts @@ -3,21 +3,19 @@ import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './ describe('formatAuthorName', () => { it('joins firstName and lastName with a space', () => { - expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' })).toBe( - 'Anna Schmidt' - ); + expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt' })).toBe('Anna Schmidt'); }); it('returns firstName alone when lastName is absent', () => { - expect(formatAuthorName({ firstName: 'Anna', email: 'a@x' })).toBe('Anna'); + expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna'); }); it('returns lastName alone when firstName is absent', () => { - expect(formatAuthorName({ lastName: 'Schmidt', email: 'a@x' })).toBe('Schmidt'); + expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt'); }); - it('falls back to email when both names are absent', () => { - expect(formatAuthorName({ email: 'fallback@example.com' })).toBe('fallback@example.com'); + it('falls back to [Unbekannt] when both names are absent', () => { + expect(formatAuthorName({})).toBe('[Unbekannt]'); }); it('returns empty string for null input', () => { diff --git a/frontend/src/lib/geschichte/utils.ts b/frontend/src/lib/geschichte/utils.ts index 36db9d05..495a9be5 100644 --- a/frontend/src/lib/geschichte/utils.ts +++ b/frontend/src/lib/geschichte/utils.ts @@ -1,12 +1,13 @@ import { formatDate } from '$lib/shared/utils/date'; -type AuthorSummary = { firstName?: string; lastName?: string; email: string }; +type AuthorSummary = { firstName?: string; lastName?: string }; type AuthorView = { displayName: string }; export function formatAuthorName(author: AuthorSummary | null | undefined): string { if (!author) return ''; const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim(); - return full || author.email || ''; + // Mirrors the server-side fallback in GeschichteService.toView — email is no longer exposed. + return full || '[Unbekannt]'; } export function formatAuthorDisplayName(author: AuthorView | null | undefined): string { 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/personOption.ts b/frontend/src/lib/person/personOption.ts new file mode 100644 index 00000000..cc594d38 --- /dev/null +++ b/frontend/src/lib/person/personOption.ts @@ -0,0 +1,26 @@ +import type { components } from '$lib/generated/api'; + +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: [p.firstName, p.lastName].filter(Boolean).join(' ') || '[Unbekannt]' + }; +} 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 59a0a846..3df6a49e 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -49,7 +49,10 @@ export type ErrorCode = | 'JOURNEY_ITEM_NOT_FOUND' | 'JOURNEY_ITEM_POSITION_CONFLICT' | 'JOURNEY_AT_CAPACITY' + | 'JOURNEY_NOTE_TOO_LONG' + | 'JOURNEY_DOCUMENT_ALREADY_ADDED' | 'GESCHICHTE_TYPE_MISMATCH' + | 'GESCHICHTE_TYPE_IMMUTABLE' | 'INVALID_CREDENTIALS' | 'SESSION_EXPIRED' | 'MISSING_CREDENTIALS' @@ -174,8 +177,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { 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_MISMATCH': return m.error_geschichte_type_mismatch(); + case 'GESCHICHTE_TYPE_IMMUTABLE': + return m.error_geschichte_type_immutable(); 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..89794289 100644 --- a/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts +++ b/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts @@ -78,6 +78,45 @@ 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('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..4f51abbe 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; } @@ -65,6 +70,9 @@ export function createTypeahead(options: Options) { get loading() { return loading; }, + get error() { + return error; + }, get activeIndex() { return activeIndex; }, 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 ce2b33d8..b0550b91 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -6,16 +6,21 @@ 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 rawDocumentId = url.searchParams.get('documentId'); + const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null; const [listResult, ...personResults] = await Promise.all([ api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', - personId: personIds.length ? personIds : undefined + personId: personIds.length ? personIds : undefined, + documentId: documentId ?? undefined } } }), @@ -32,6 +37,7 @@ export const load: PageServerLoad = async ({ url, fetch }) => { return { geschichten: listResult.data ?? [], - personFilters + personFilters, + documentIdFilter: documentId }; }; diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte index dede091b..472b5238 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,6 +13,8 @@ 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; @@ -44,7 +47,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} @@ -56,5 +60,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 25d8b4cd..4f0a3a11 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -76,7 +76,7 @@ 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: { diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte index 0eff0a68..fe03ae66 100644 --- a/frontend/src/routes/geschichten/new/+page.svelte +++ b/frontend/src/routes/geschichten/new/+page.svelte @@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js'; import BackButton from '$lib/shared/primitives/BackButton.svelte'; import TypeSelector from './TypeSelector.svelte'; import StoryCreate from './StoryCreate.svelte'; +import JourneyCreate from './JourneyCreate.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -19,12 +20,7 @@ let { data }: { data: PageData } = $props(); {#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..cedd7d8b --- /dev/null +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte @@ -0,0 +1,86 @@ + + +
+ {#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..30ee0549 --- /dev/null +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts @@ -0,0 +1,73 @@ +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('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/page.svelte.test.ts b/frontend/src/routes/geschichten/new/page.svelte.test.ts index 26a0ad49..ccf76f8d 100644 --- a/frontend/src/routes/geschichten/new/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/new/page.svelte.test.ts @@ -73,14 +73,13 @@ describe('geschichten/new page', () => { await expect.element(page.getByRole('radiogroup')).toBeVisible(); }); - it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => { + it('shows JourneyCreate form when selectedType is JOURNEY', async () => { render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); - const placeholder = document.querySelector('[data-testid="journey-placeholder"]'); - expect(placeholder).not.toBeNull(); + await expect.element(page.getByRole('button', { name: /Lesereise erstellen/i })).toBeVisible(); }); - it('JOURNEY placeholder offers a return-to-selection link', async () => { + 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 }); 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..1a802095 --- /dev/null +++ b/frontend/src/routes/geschichten/page.server.test.ts @@ -0,0 +1,115 @@ +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 mockApi() { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: [] + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + return mockGet; +} + +function callLoad(url: URL) { + return load({ + url, + request: new Request('http://localhost/geschichten'), + fetch: vi.fn() as unknown as typeof fetch + }); +} + +// ─── documentId filter forwarding ───────────────────────────────────────────── + +describe('geschichten page load — documentId filter', () => { + it('passes a 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 API call when the value is not a UUID', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + const query = mockGet.mock.calls[0][1].params.query; + expect(query.documentId).toBeUndefined(); + }); + + it('omits documentId from the API call when the param is absent', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl()); + + const query = mockGet.mock.calls[0][1].params.query; + expect(query.documentId).toBeUndefined(); + }); + + it('returns documentIdFilter in page data when a valid documentId is given', async () => { + mockApi(); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentIdFilter).toBe(VALID_UUID); + }); + + it('returns null documentIdFilter when documentId is invalid', async () => { + mockApi(); + + const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + expect(result.documentIdFilter).toBeNull(); + }); + + 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.test.ts b/frontend/src/routes/geschichten/page.svelte.test.ts index 663859b9..0cb6b8a7 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, @@ -127,7 +127,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 +139,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 +147,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 +178,7 @@ describe('geschichten/+ page', () => { { id: 'g1', title: 'Draft', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } + author: { firstName: 'Anna', lastName: 'Schmidt' } } ] }) @@ -202,7 +202,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 8990d355..dfd048ff 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -77,11 +77,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); @@ -139,6 +149,16 @@ --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; @@ -263,6 +283,17 @@ --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; } } @@ -343,6 +374,16 @@ --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 ──── */