From 5f2ef823e18b73029c5314844ef7c94e512781de Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:31:40 +0200 Subject: [PATCH 01/19] fix(document): order undated documents last on the DATE sort fast path resolveSort produced Sort.by(direction, "documentDate") with NATIVE null handling, so Postgres surfaced undated (null meta_date) documents FIRST on an ASC sort. Apply nullsLast() so undated rows order last for both ASC and DESC, with a createdAt-asc tiebreaker for a stable total order when every row is null-dated (the upcoming "Nur undatierte" filter). Refs #668 --- .../document/DocumentService.java | 11 ++++-- .../document/DocumentServiceTest.java | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) 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 4106332a..1cef9593 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -797,10 +797,17 @@ public class DocumentService { return transcriptionBlockQueryService.getCompletionStats(docIds); } - private Sort resolveSort(DocumentSort sort, String dir) { + Sort resolveSort(DocumentSort sort, String dir) { Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC; if (sort == null || sort == DocumentSort.DATE || sort == DocumentSort.RELEVANCE) { - return Sort.by(direction, "documentDate"); + // Undated documents (null documentDate) must order last regardless of + // direction — Postgres puts NULLs FIRST on ASC by default, which would + // surface the undated pile at the top with no explanation (issue #668). + // The createdAt tiebreaker gives a stable total order when every row is + // null-dated (the "Nur undatierte" filter), so pagination is deterministic. + return Sort.by( + new Sort.Order(direction, "documentDate").nullsLast(), + Sort.Order.asc("createdAt")); } // SENDER and RECEIVER are sorted in-memory before this method is called return switch (sort) { 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 658d4c31..32a4b3b9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -1450,6 +1450,42 @@ class DocumentServiceTest { assertThat(result.items()).hasSize(1); // only the slice is enriched } + @Test + void searchDocuments_dateSort_DESC_ordersUndatedLast() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + documentService.searchDocuments(null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, + org.springframework.data.domain.PageRequest.of(0, 5)); + + verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); + Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate"); + assertThat(dateOrder).isNotNull(); + assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.DESC); + assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST); + } + + @Test + void searchDocuments_dateSort_ASC_ordersUndatedLast() { + // The ASC bug: Postgres puts NULLs FIRST on ascending sort without explicit + // NULLS LAST, surfacing undated documents at the top. This is the red. + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + documentService.searchDocuments(null, null, null, null, null, null, null, null, + DocumentSort.DATE, "ASC", null, + org.springframework.data.domain.PageRequest.of(0, 5)); + + verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); + Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate"); + assertThat(dateOrder).isNotNull(); + assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.ASC); + assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST); + } + @Test void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() { ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); -- 2.49.1 From 39a462b2bb32177b14a8b9600acddea82cd50792 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:34:10 +0200 Subject: [PATCH 02/19] feat(document): add undatedOnly Specification for the undated-only filter undatedOnly(false) is a no-op (null predicate); undatedOnly(true) returns documentDate IS NULL, matching the existing hasStatus null-as-no-op pattern. Real-Postgres tests pin the load-bearing guarantees H2 cannot prove: ASC NULLS-LAST ordering, BETWEEN excludes null-dated rows, and that undated=true combined with a from/to range returns empty (the collision rule). Refs #668 --- .../document/DocumentSpecifications.java | 6 + .../document/DocumentSpecificationsTest.java | 17 +++ ...ndatedDocumentOrderingIntegrationTest.java | 104 ++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java index 22339a95..ff238a43 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java @@ -55,6 +55,12 @@ public class DocumentSpecifications { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } + // Filtert auf undatierte Dokumente (meta_date IS NULL) — für die "Nur undatierte"-Triage. + // false → kein Prädikat (no-op), true → documentDate IS NULL (issue #668). + public static Specification undatedOnly(boolean undated) { + return (root, query, cb) -> undated ? cb.isNull(root.get("documentDate")) : null; + } + /** * Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik. * diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java index 7af1ec22..b9f8a46d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java @@ -261,4 +261,21 @@ class DocumentSpecificationsTest { assertThat(result).isEmpty(); } + // ─── undatedOnly ────────────────────────────────────────────────────────── + + @Test + void undatedOnly_false_returnsAllDocuments() { + // false → no predicate (null), so the filter is a no-op (issue #668). + List result = documentRepository.findAll(Specification.where(undatedOnly(false))); + assertThat(result).hasSize(3); + } + + @Test + void undatedOnly_true_returnsOnlyDocumentsWithoutADate() { + // Only the placeholder photo has a null documentDate in the fixture. + List result = documentRepository.findAll(Specification.where(undatedOnly(true))); + assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto"); + assertThat(result).allMatch(d -> d.getDocumentDate() == null); + } + } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java new file mode 100644 index 00000000..e7e50d74 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java @@ -0,0 +1,104 @@ +package org.raddatz.familienarchiv.document; + +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.raddatz.familienarchiv.document.DocumentSpecifications.isBetween; +import static org.raddatz.familienarchiv.document.DocumentSpecifications.undatedOnly; + +/** + * Real-Postgres assertions for issue #668. H2 disagrees with Postgres on + * {@code NULLS FIRST/LAST} defaults and on whether {@code BETWEEN} excludes + * NULL, so these guarantees MUST run against {@code postgres:16-alpine}, never + * an in-memory database. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class UndatedDocumentOrderingIntegrationTest { + + @Autowired DocumentRepository documentRepository; + + @BeforeEach + void setUp() { + documentRepository.deleteAll(); + save("1916", LocalDate.of(1916, 6, 15)); + save("1943", LocalDate.of(1943, 12, 24)); + save("undated-a", null); + save("undated-b", null); + } + + private void save(String title, LocalDate date) { + documentRepository.save(Document.builder() + .title(title) + .originalFilename(title + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(date == null ? DatePrecision.UNKNOWN : DatePrecision.DAY) + .documentDate(date) + .build()); + } + + @Test + void dateAscWithNullsLast_returnsDatedFirstUndatedLast() { + Sort sort = Sort.by(new Sort.Order(Sort.Direction.ASC, "documentDate").nullsLast()); + + List result = documentRepository.findAll(sort); + + assertThat(result).hasSize(4); + assertThat(result.get(0).getDocumentDate()).isEqualTo(LocalDate.of(1916, 6, 15)); + assertThat(result.get(1).getDocumentDate()).isEqualTo(LocalDate.of(1943, 12, 24)); + assertThat(result.get(2).getDocumentDate()).isNull(); + assertThat(result.get(3).getDocumentDate()).isNull(); + } + + @Test + void undatedOnly_returnsExactlyTheNullDatedRows() { + List result = documentRepository.findAll(undatedOnly(true)); + + assertThat(result).hasSize(2); + assertThat(result).allMatch(d -> d.getDocumentDate() == null); + } + + @Test + void undatedOnly_false_returnsAllRows() { + Specification spec = Specification.where(undatedOnly(false)); + + List result = documentRepository.findAll(spec); + + assertThat(result).hasSize(4); + } + + @Test + void dateRange_excludesUndatedRows() { + List result = documentRepository.findAll(isBetween( + LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31))); + + assertThat(result).hasSize(2); + assertThat(result).allMatch(d -> d.getDocumentDate() != null); + } + + @Test + void undatedOnly_combinedWithDateRange_returnsEmpty() { + // The collision rule (#668): a from/to range and undated=true are mutually + // exclusive — a row cannot both have a null date and fall inside a range. + Specification spec = Specification + .where(undatedOnly(true)) + .and(isBetween(LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31))); + + List result = documentRepository.findAll(spec); + + assertThat(result).isEmpty(); + } +} -- 2.49.1 From 268c31a49bd9374c00a07d0076e451d6b5b60541 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:42:17 +0200 Subject: [PATCH 03/19] feat(document): thread an undated filter through search and the /ids path Adds an optional `undated` query param to GET /api/documents/search and /api/documents/ids, threaded through searchDocuments and findIdsForFilter into the shared buildSearchSpec via undatedOnly(boolean). undated=true also bypasses the pure-text RELEVANCE SQL shortcut, which skips buildSearchSpec and would otherwise drop the predicate. The read GET stays unguarded (WebMvc authz test pins 200 for an authenticated user, 401 unauthenticated). A locking test proves the in-memory SENDER sort keeps undated letters under their sender. Refs #668 --- .../document/DocumentController.java | 6 +- .../document/DocumentService.java | 21 +++-- .../document/DocumentControllerTest.java | 75 +++++++++++++--- .../document/DocumentLazyLoadingTest.java | 6 +- .../DocumentListItemIntegrationTest.java | 9 +- .../DocumentSearchPagedIntegrationTest.java | 18 ++-- .../document/DocumentServiceSortTest.java | 14 +-- .../document/DocumentServiceTest.java | 90 +++++++++++++------ 8 files changed, 159 insertions(+), 80 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java index f4bf72d3..daaa96c5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -313,9 +313,10 @@ public class DocumentController { @RequestParam(required = false) String tagQ, @RequestParam(required = false) DocumentStatus status, @RequestParam(required = false) String tagOp, + @RequestParam(required = false) Boolean undated, Authentication authentication) { TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; - List ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator); + List ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated)); if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) { throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, "Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")"); @@ -375,6 +376,7 @@ public class DocumentController { @Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort, @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir, @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp, + @Parameter(description = "Restrict to undated documents (meta_date IS NULL)") @RequestParam(required = false) Boolean undated, // @Max on page guards against overflow when pageable.getOffset() is computed // as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which // Hibernate cheerfully turns into an invalid SQL OFFSET. @@ -387,7 +389,7 @@ public class DocumentController { // defaults to AND, which matches the frontend default and keeps old clients working. TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; Pageable pageable = PageRequest.of(page, size); - return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable)); + return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, Boolean.TRUE.equals(undated), pageable)); } @GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE) 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 1cef9593..43d206b7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -171,7 +171,7 @@ public class DocumentService { hasFts, ftsIds, null, null, filters.sender(), filters.receiver(), filters.tags(), filters.tagQ(), - filters.status(), filters.tagOperator()); + filters.status(), filters.tagOperator(), false); return documentRepository.findAll(spec).stream() .map(Document::getDocumentDate) .filter(Objects::nonNull) @@ -501,7 +501,8 @@ public class DocumentService { */ @Transactional(readOnly = true) public List findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, - List tags, String tagQ, DocumentStatus status, TagOperator tagOperator) { + List tags, String tagQ, DocumentStatus status, TagOperator tagOperator, + boolean undated) { boolean hasText = StringUtils.hasText(text); List rankedIds = null; if (hasText) { @@ -510,7 +511,7 @@ public class DocumentService { } Specification spec = buildSearchSpec( - hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator); + hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated); return documentRepository.findAll(spec).stream().map(Document::getId).toList(); } @@ -524,7 +525,8 @@ public class DocumentService { LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, - DocumentStatus status, TagOperator tagOperator) { + DocumentStatus status, TagOperator tagOperator, + boolean undated) { boolean useOrLogic = tagOperator == TagOperator.OR; List> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); Specification textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null; @@ -534,7 +536,8 @@ public class DocumentService { .and(hasReceiver(receiver)) .and(hasTags(expandedTagSets, useOrLogic)) .and(hasTagPartial(tagQ)) - .and(hasStatus(status)); + .and(hasStatus(status)) + .and(undatedOnly(undated)); } /** @@ -663,11 +666,13 @@ public class DocumentService { } // 1. Allgemeine Suche (für das Suchfeld im Frontend) - public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) { + public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) { boolean hasText = StringUtils.hasText(text); // Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008). - if (isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) { + // An active undated filter must NOT take this path: it bypasses buildSearchSpec, so the + // undatedOnly predicate would be silently dropped. + if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) { return relevanceSortedPageFromSql(text, pageable); } @@ -678,7 +683,7 @@ public class DocumentService { } Specification spec = buildSearchSpec( - hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator); + hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated); // SENDER and RECEIVER sorts load the full match set and slice in-memory. // JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index 00b69ce5..8608ed83 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.document; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.document.DocumentSearchResult; import org.raddatz.familienarchiv.document.DocumentVersionSummary; @@ -35,7 +36,9 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -73,23 +76,69 @@ class DocumentControllerTest { @Test @WithMockUser void search_returns200_whenAuthenticated() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()); } + @Test + @WithMockUser + void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception { + // The read GET must stay reachable for READ_ALL users — guards against a + // future refactor accidentally write-guarding the undated triage path (#668). + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + + mockMvc.perform(get("/api/documents/search").param("undated", "true")) + .andExpect(status().isOk()); + } + + @Test + void search_undatedTrue_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/search").param("undated", "true")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception { + ArgumentCaptor undatedCaptor = ArgumentCaptor.forClass(Boolean.class); + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + + mockMvc.perform(get("/api/documents/search").param("undated", "true")) + .andExpect(status().isOk()); + + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any()); + assertThat(undatedCaptor.getValue()).isTrue(); + } + + @Test + @WithMockUser + void search_withoutUndatedParam_forwardsFalseToService() throws Exception { + ArgumentCaptor undatedCaptor = ArgumentCaptor.forClass(Boolean.class); + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + + mockMvc.perform(get("/api/documents/search")) + .andExpect(status().isOk()); + + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any()); + assertThat(undatedCaptor.getValue()).isFalse(); + } + @Test @WithMockUser void search_withStatusParam_passesItToService() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) .andExpect(status().isOk()); - verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()); + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any()); } @Test @@ -116,7 +165,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_responseContainsTotalCount() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search")) @@ -131,7 +180,7 @@ class DocumentControllerTest { UUID docId = UUID.randomUUID(); var matchData = new SearchMatchData( "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, DatePrecision.UNKNOWN, null, null, @@ -150,7 +199,7 @@ class DocumentControllerTest { void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception { UUID docId = UUID.randomUUID(); var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of()); - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, DatePrecision.UNKNOWN, null, null, @@ -172,7 +221,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_responseExposesPagingFields() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search")) @@ -217,7 +266,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_passesPageRequestToService() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25")) @@ -225,7 +274,7 @@ class DocumentControllerTest { org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class); - verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture()); + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), captor.capture()); org.springframework.data.domain.Pageable pageable = captor.getValue(); org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2); org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25); @@ -1143,7 +1192,7 @@ class DocumentControllerTest { void getDocumentIds_returns200_andDelegatesToService() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); UUID id = UUID.randomUUID(); - when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean())) .thenReturn(List.of(id)); mockMvc.perform(get("/api/documents/ids")) @@ -1156,13 +1205,13 @@ class DocumentControllerTest { void getDocumentIds_passesSenderIdParamToService() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); UUID senderId = UUID.randomUUID(); - when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any())) + when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean())) .thenReturn(List.of()); mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString())) .andExpect(status().isOk()); - verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()); + verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -1172,7 +1221,7 @@ class DocumentControllerTest { // Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000). java.util.List tooMany = new java.util.ArrayList<>(5001); for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID()); - when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean())) .thenReturn(tooMany); mockMvc.perform(get("/api/documents/ids")) 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 62a2d843..1b5a4b1e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java @@ -123,8 +123,7 @@ class DocumentLazyLoadingTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.RECEIVER, "asc", null, - PageRequest.of(0, 20)); + DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20)); assertThat(result.totalElements()).isGreaterThan(0); assertThatCode(() -> result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); })) @@ -139,8 +138,7 @@ class DocumentLazyLoadingTest { assertThatCode(() -> documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.SENDER, "asc", null, - PageRequest.of(0, 20))) + DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20))) .doesNotThrowAnyException(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentListItemIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentListItemIntegrationTest.java index d97aaf9c..3d0e4b90 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentListItemIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentListItemIntegrationTest.java @@ -56,8 +56,7 @@ class DocumentListItemIntegrationTest { assertThatCode(() -> documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(0, 50))) + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50))) .doesNotThrowAnyException(); } @@ -72,8 +71,7 @@ class DocumentListItemIntegrationTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(0, 50)); + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); assertThat(result.totalElements()).isGreaterThan(0); DocumentListItem item = result.items().get(0); @@ -94,8 +92,7 @@ class DocumentListItemIntegrationTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(0, 50)); + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); DocumentListItem item = result.items().stream() .filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java index c61c38af..05c7e025 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java @@ -62,8 +62,7 @@ class DocumentSearchPagedIntegrationTest { void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(0, 50)); + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); assertThat(result.items()).hasSize(50); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); @@ -76,8 +75,7 @@ class DocumentSearchPagedIntegrationTest { void search_lastPartialPage_returnsRemainingItems() { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(2, 50)); + DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50)); // Page 2 (offset 100) of 120 docs → exactly 20 items on the tail. assertThat(result.items()).hasSize(20); @@ -89,8 +87,7 @@ class DocumentSearchPagedIntegrationTest { void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(99, 50)); + DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50)); assertThat(result.items()).isEmpty(); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); @@ -103,8 +100,7 @@ class DocumentSearchPagedIntegrationTest { // returns the correct total from a real repository fetch. DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.SENDER, "asc", null, - PageRequest.of(1, 50)); + DocumentSort.SENDER, "asc", null, false, PageRequest.of(1, 50)); assertThat(result.items()).hasSize(50); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); @@ -116,12 +112,10 @@ class DocumentSearchPagedIntegrationTest { void search_differentPagesReturnDisjointSlices() { DocumentSearchResult page0 = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(0, 50)); + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); DocumentSearchResult page1 = documentService.searchDocuments( null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - PageRequest.of(1, 50)); + DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50)); // No document id should appear on both pages — slicing must be exclusive. var idsOnPage0 = page0.items().stream() 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 abf6e389..c3d00619 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java @@ -67,7 +67,7 @@ class DocumentServiceSortTest { .thenReturn(new PageImpl<>(List.of(newer, older))); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE); + "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, false, PAGE); assertThat(result.items()).hasSize(2); assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first @@ -84,7 +84,7 @@ class DocumentServiceSortTest { .thenReturn(List.of(doc(id1))); documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE); verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt()); verify(documentRepository, never()).findAllMatchingIdsByFts(anyString()); @@ -102,7 +102,7 @@ class DocumentServiceSortTest { when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE); assertThat(result.items().get(0).id()).isEqualTo(id1); } @@ -119,7 +119,7 @@ class DocumentServiceSortTest { when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, null, null, null, PAGE); + "Brief", null, null, null, null, null, null, null, null, null, null, false, PAGE); assertThat(result.items().get(0).id()).isEqualTo(id1); } @@ -133,7 +133,7 @@ class DocumentServiceSortTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, - DocumentSort.RELEVANCE, null, null, hugePage); + DocumentSort.RELEVANCE, null, null, false, hugePage); assertThat(result.items()).isEmpty(); verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt()); @@ -153,7 +153,7 @@ class DocumentServiceSortTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, - DocumentSort.RELEVANCE, null, null, PAGE); + DocumentSort.RELEVANCE, null, null, false, PAGE); assertThat(result.items()).hasSize(1); assertThat(result.items().get(0).id()).isEqualTo(uuidId); @@ -173,7 +173,7 @@ class DocumentServiceSortTest { // sender filter is active → triggers in-memory path, not findFtsPageRaw LocalDate from = LocalDate.of(1900, 1, 1); documentService.searchDocuments( - "Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE); + "Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE); verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt()); verify(documentRepository).findAllMatchingIdsByFts("Brief"); 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 32a4b3b9..757284ca 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -47,6 +47,8 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; @@ -1409,8 +1411,7 @@ class DocumentServiceTest { .thenReturn(new PageImpl<>(List.of())); documentService.searchDocuments(null, null, null, null, null, null, null, null, - org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, - org.springframework.data.domain.PageRequest.of(1, 50)); + org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)); verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); @@ -1423,8 +1424,7 @@ class DocumentServiceTest { .thenReturn(new PageImpl<>(List.of())); documentService.searchDocuments(null, null, null, null, null, null, null, null, - org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, - org.springframework.data.domain.PageRequest.of(3, 25)); + org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); assertThat(captor.getValue().getPageNumber()).isEqualTo(3); @@ -1440,8 +1440,7 @@ class DocumentServiceTest { .thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L)); DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, - org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, - org.springframework.data.domain.PageRequest.of(0, 50)); + org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 50)); assertThat(result.totalElements()).isEqualTo(120L); assertThat(result.pageNumber()).isZero(); @@ -1457,8 +1456,7 @@ class DocumentServiceTest { .thenReturn(new PageImpl<>(List.of())); documentService.searchDocuments(null, null, null, null, null, null, null, null, - DocumentSort.DATE, "DESC", null, - org.springframework.data.domain.PageRequest.of(0, 5)); + DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate"); @@ -1476,8 +1474,7 @@ class DocumentServiceTest { .thenReturn(new PageImpl<>(List.of())); documentService.searchDocuments(null, null, null, null, null, null, null, null, - DocumentSort.DATE, "ASC", null, - org.springframework.data.domain.PageRequest.of(0, 5)); + DocumentSort.DATE, "ASC", null, false, org.springframework.data.domain.PageRequest.of(0, 5)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate"); @@ -1493,8 +1490,7 @@ class DocumentServiceTest { .thenReturn(new PageImpl<>(List.of())); documentService.searchDocuments(null, null, null, null, null, null, null, null, - DocumentSort.UPDATED_AT, "DESC", null, - org.springframework.data.domain.PageRequest.of(0, 5)); + DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); assertThat(captor.getValue().getSort()) @@ -1518,8 +1514,7 @@ class DocumentServiceTest { .thenReturn(all); DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, - org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, - org.springframework.data.domain.PageRequest.of(1, 50)); + org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50)); assertThat(result.totalElements()).isEqualTo(120L); assertThat(result.pageNumber()).isEqualTo(1); @@ -1543,8 +1538,7 @@ class DocumentServiceTest { .thenReturn(all); DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, - org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, - org.springframework.data.domain.PageRequest.of(10, 50)); + org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50)); assertThat(result.items()).isEmpty(); assertThat(result.totalElements()).isEqualTo(30L); @@ -1557,7 +1551,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of())); - documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED); + documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, false, UNPAGED); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)); } @@ -1567,7 +1561,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of())); - documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED); + documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)); } @@ -1645,7 +1639,7 @@ class DocumentServiceTest { .thenReturn(List.of(withSender, noSender)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED); + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED); assertThat(result.items()).hasSize(2); assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender"); @@ -1665,12 +1659,53 @@ class DocumentServiceTest { .thenReturn(List.of(noReceivers, withReceiver)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED); + null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, false, UNPAGED); assertThat(result.items()).extracting(DocumentListItem::title) .containsExactly("Has Receiver", "No Receivers"); } + // ─── searchDocuments — undated docs stay in their person group (#668) ─────── + + @Test + void searchDocuments_senderSort_keepsUndatedDocumentUnderItsSender() { + // Locking test: the in-memory SENDER comparator orders by sender name, not + // date, so an undated (null documentDate) letter must NOT be pulled out of + // its sender's group — it sorts by sender exactly like a dated letter. + Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build(); + Document datedFromAlice = Document.builder().id(UUID.randomUUID()).title("Dated") + .sender(alice).documentDate(LocalDate.of(1916, 6, 15)).build(); + Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated") + .sender(alice).documentDate(null).build(); + + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) + .thenReturn(List.of(datedFromAlice, undatedFromAlice)); + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED); + + // Both stay together under Alice; neither is dropped or reordered by date. + assertThat(result.items()).extracting(DocumentListItem::title) + .containsExactlyInAnyOrder("Dated", "Undated"); + assertThat(result.items()).allMatch(item -> item.sender().getId().equals(alice.getId())); + } + + @Test + void searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut() { + // undated=true must bypass the pure-text RELEVANCE SQL shortcut, which + // skips buildSearchSpec and would silently drop the undatedOnly predicate. + when(documentRepository.findAllMatchingIdsByFts("brief")).thenReturn(List.of(UUID.randomUUID())); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) + .thenReturn(List.of()); + + documentService.searchDocuments("brief", null, null, null, null, null, null, null, + DocumentSort.RELEVANCE, null, null, true, UNPAGED); + + // The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not. + verify(documentRepository).findAllMatchingIdsByFts("brief"); + verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt()); + } + @Test void searchDocuments_senderSort_nullLastNameSortsToEnd() { // Without fix: null lastName produces sort key "null Smith" which compares @@ -1687,7 +1722,7 @@ class DocumentServiceTest { .thenReturn(List.of(docNullName, docSmith)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED); + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED); // null lastName should sort to end (treated as empty), not before "smith" (as "null") assertThat(result.items()).extracting(DocumentListItem::title) @@ -1710,7 +1745,7 @@ class DocumentServiceTest { when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED); assertThat(result.items()).hasSize(1); SearchMatchData md = result.items().get(0).matchData(); @@ -1724,8 +1759,7 @@ class DocumentServiceTest { .thenReturn(new PageImpl<>(List.of())); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, null, null, null, - UNPAGED); + null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED); assertThat(result.items()).isEmpty(); } @@ -1745,7 +1779,7 @@ class DocumentServiceTest { when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED); SearchMatchData md = result.items().get(0).matchData(); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); @@ -2262,7 +2296,7 @@ class DocumentServiceTest { .thenReturn(List.of(d1, d2)); List result = documentService.findIdsForFilter( - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, false); assertThat(result).containsExactly(d1.getId(), d2.getId()); } @@ -2277,7 +2311,7 @@ class DocumentServiceTest { when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); documentService.findIdsForFilter( - null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR); + null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false); // Spec built without throwing → OR branch was exercised. Coverage gain // is in not-throwing on the OR-specific code path; the actual SQL is @@ -2290,7 +2324,7 @@ class DocumentServiceTest { when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of()); List result = documentService.findIdsForFilter( - "xyz", null, null, null, null, null, null, null, null); + "xyz", null, null, null, null, null, null, null, null, false); assertThat(result).isEmpty(); verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class)); -- 2.49.1 From f1fc3dc1ce862912cd34e71a631cd9024fe961d0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:45:03 +0200 Subject: [PATCH 04/19] feat(documents): thread undated filter through the search loader + i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses ?undated strictly (=== 'true', mirroring the tagOp clamp), forwards it as undated || undefined so the absent case drops out of the query, and returns the flag in page data for the control to reflect. Adds the docs_filter_undated_only toggle label and the explanatory docs_range_excludes_undated empty-state copy in de/en/es. The badge reuses the existing date_precision_unknown ("Datum unbekannt") key from #677. OpenAPI types hand-edited for the new undated query param on /search and /ids — CI must run `npm run generate:api` to confirm parity with the spec. Refs #668 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + frontend/src/lib/generated/api.ts | 3 + frontend/src/routes/documents/+page.server.ts | 5 + .../src/routes/documents/page.server.spec.ts | 100 ++++++++++++++++++ 6 files changed, 114 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7358637a..f2524003 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -100,6 +100,8 @@ "docs_list_summary": "Zusammenfassung", "docs_list_unknown": "Unbekannt", "docs_group_undated": "Undatiert", + "docs_filter_undated_only": "Nur undatierte", + "docs_range_excludes_undated": "Ein Datumsfilter schließt undatierte Dokumente aus, da sie keinem Zeitraum zugeordnet werden können.", "docs_group_unknown": "Unbekannt", "doc_section_who_when": "Wer & Wann", "doc_section_description": "Beschreibung", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 77097540..33e7f222 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -100,6 +100,8 @@ "docs_list_summary": "Summary", "docs_list_unknown": "Unknown", "docs_group_undated": "Undated", + "docs_filter_undated_only": "Undated only", + "docs_range_excludes_undated": "A date range filter excludes undated documents, because they cannot belong to any time span.", "docs_group_unknown": "Unknown", "doc_section_who_when": "Who & When", "doc_section_description": "Description", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 47906c0c..e8859767 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -100,6 +100,8 @@ "docs_list_summary": "Resumen", "docs_list_unknown": "Desconocido", "docs_group_undated": "Sin fecha", + "docs_filter_undated_only": "Solo sin fecha", + "docs_range_excludes_undated": "Un filtro de intervalo de fechas excluye los documentos sin fecha, ya que no pueden pertenecer a ningún periodo.", "docs_group_unknown": "Desconocido", "doc_section_who_when": "Quién & Cuándo", "doc_section_description": "Descripción", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 48b4b83d..9c901d56 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -5083,6 +5083,8 @@ export interface operations { dir?: string; /** @description Tag operator: AND (default) or OR */ tagOp?: string; + /** @description Restrict to undated documents (meta_date IS NULL) */ + undated?: boolean; /** @description Page number (0-indexed) */ page?: number; /** @description Page size (max 100) */ @@ -5184,6 +5186,7 @@ export interface operations { tagQ?: string; status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; tagOp?: string; + undated?: boolean; }; header?: never; path?: never; diff --git a/frontend/src/routes/documents/+page.server.ts b/frontend/src/routes/documents/+page.server.ts index 16186611..acd8f666 100644 --- a/frontend/src/routes/documents/+page.server.ts +++ b/frontend/src/routes/documents/+page.server.ts @@ -46,6 +46,8 @@ export async function load({ url, fetch }) { : 'desc'; const tagQ = url.searchParams.get('tagQ') || ''; const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND'; + // Narrow the accepted truthy surface to exactly "true" (mirrors the tagOp clamp). + const undated = url.searchParams.get('undated') === 'true'; const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0); const api = createApiClient(fetch); @@ -66,6 +68,7 @@ export async function load({ url, fetch }) { tag: tags.length ? tags : undefined, tagQ: tagQ && !tags.length ? tagQ : undefined, tagOp: tagOp === 'OR' ? 'OR' : undefined, + undated: undated || undefined, sort, dir: dir || undefined, page, @@ -94,6 +97,7 @@ export async function load({ url, fetch }) { dir, tagQ, tagOp, + undated, error: 'Daten konnten nicht geladen werden.' as string | null }; } @@ -124,6 +128,7 @@ export async function load({ url, fetch }) { dir, tagQ, tagOp, + undated, error: errorMessage }; } diff --git a/frontend/src/routes/documents/page.server.spec.ts b/frontend/src/routes/documents/page.server.spec.ts index 2ed33a86..69726562 100644 --- a/frontend/src/routes/documents/page.server.spec.ts +++ b/frontend/src/routes/documents/page.server.spec.ts @@ -100,6 +100,106 @@ describe('documents page load — search params', () => { ); }); + it('forwards undated=true to the search API as a boolean true', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ undated: 'true' }), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet).toHaveBeenCalledWith( + '/api/documents/search', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ undated: true }) + }) + }) + ); + }); + + it('omits undated from the query when the param is absent', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({}), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); + + const query = mockGet.mock.calls[0][1].params.query; + expect(query.undated).toBeUndefined(); + }); + + it('treats any undated value other than the literal "true" as not-undated', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ undated: '1' }), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.undated).toBe(false); + expect(mockGet.mock.calls[0][1].params.query.undated).toBeUndefined(); + }); + + it('returns the undated flag in page data when enabled', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ undated: 'true' }), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.undated).toBe(true); + }); + + it('does not carry page when toggling undated (page reset)', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + // A bare undated toggle URL carries no page param → loader requests page 0. + await load({ + url: makeUrl({ undated: 'true' }), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet.mock.calls[0][1].params.query.page).toBe(0); + }); + it('returns items and total from the search result', async () => { const item = { document: { id: 'd1' }, -- 2.49.1 From bca3f34ceca265b8e7633789f197b437e23b1d25 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:48:45 +0200 Subject: [PATCH 05/19] feat(documents): badge undated rows instead of a bare em-dash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentRow rendered a bare em-dash for null-dated letters — a glyph a screen reader announces as nothing. Both breakpoints now render the single DocumentDate component unconditionally (no {#if}/—/{:else}), so the cue cannot drift; its unknown state is a neutral metadata chip ("Datum unbekannt", text-ink-3, ≥4.5:1 both themes) with a non-color calendar glyph, never red/amber. Present dates render at honest precision via formatDocumentDate ("Juni 1916", not a fabricated day). Refs #668 --- frontend/src/lib/document/DocumentDate.svelte | 30 +++++++++++++------ frontend/src/lib/document/DocumentRow.svelte | 16 ++++------ .../lib/document/DocumentRow.svelte.spec.ts | 29 ++++++++++++++++++ 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/document/DocumentDate.svelte b/frontend/src/lib/document/DocumentDate.svelte index a3539c31..937b5f7c 100644 --- a/frontend/src/lib/document/DocumentDate.svelte +++ b/frontend/src/lib/document/DocumentDate.svelte @@ -28,13 +28,21 @@ const showRawLine = $derived( - - {#if isUnknown} - + {#if isUnknown} + + - {/if} - {label} - + {label} + + {:else} + + {label} + + {/if} {#if showRawLine} - {#if doc.documentDate} - - {:else} - — - {/if} +
diff --git a/frontend/src/lib/document/DocumentRow.svelte.spec.ts b/frontend/src/lib/document/DocumentRow.svelte.spec.ts index 9511edc0..bb9609c2 100644 --- a/frontend/src/lib/document/DocumentRow.svelte.spec.ts +++ b/frontend/src/lib/document/DocumentRow.svelte.spec.ts @@ -73,6 +73,35 @@ describe('DocumentRow – title', () => { }); }); +// ─── Date rendering (#668) ────────────────────────────────────────────────── + +describe('DocumentRow – date rendering', () => { + it('renders a "Datum unbekannt" badge for an undated document', async () => { + const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' }); + render(DocumentRow, { item }); + // The badge text appears (once per breakpoint block). + await expect.element(page.getByText('Datum unbekannt').first()).toBeInTheDocument(); + }); + + it('does not render a bare em-dash for an undated document', async () => { + const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' }); + render(DocumentRow, { item }); + await expect.element(page.getByText('—', { exact: true }).first()).not.toBeInTheDocument(); + }); + + it('renders the full date for a day-precision document', async () => { + const item = makeItem({ documentDate: '1943-12-24', metaDatePrecision: 'DAY' }); + render(DocumentRow, { item }); + await expect.element(page.getByText(/24\. Dezember 1943/).first()).toBeInTheDocument(); + }); + + it('renders month precision honestly without fabricating a day', async () => { + const item = makeItem({ documentDate: '1916-06-01', metaDatePrecision: 'MONTH' }); + render(DocumentRow, { item }); + await expect.element(page.getByText(/Juni 1916/).first()).toBeInTheDocument(); + }); +}); + // ─── Snippet ────────────────────────────────────────────────────────────────── describe('DocumentRow – snippet', () => { -- 2.49.1 From 5d8bb70255fb5e40787af561cb5bcfb30ea74777 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:50:18 +0200 Subject: [PATCH 06/19] feat(documents): explain that a date range excludes undated documents DocumentList gains from/to props; when a date range is active and yields no results, the empty state shows the localized docs_range_excludes_undated note instead of the generic copy, so the reader understands undated letters aren't part of a range. Person-grouped modes keep undated letters under their sender/receiver (badge-on-row, no synthetic sub-group). Refs #668 --- frontend/src/routes/DocumentList.svelte | 19 ++++- .../src/routes/DocumentList.svelte.spec.ts | 83 +++++++++++++++++++ frontend/src/routes/documents/+page.svelte | 2 + 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 8d29c870..e0cc4d17 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -15,7 +15,9 @@ let { error, total = 0, q = '', - sort = 'DATE' + sort = 'DATE', + from = '', + to = '' }: { items: DocumentListItem[]; canWrite: boolean; @@ -23,8 +25,15 @@ let { total?: number; q?: string; sort?: SortMode; + from?: string; + to?: string; } = $props(); +// A from/to range excludes undated documents — when it yields nothing, the +// empty state must say so explicitly (a localized constant, never a reflected +// backend string). Issue #668. +const hasDateRange = $derived(!!from || !!to); + const groups = $derived.by(() => { if (sort === 'SENDER') return groupBySender(items); if (sort === 'RECEIVER') return groupByReceiver(items); @@ -119,7 +128,13 @@ function groupByReceiver(docItems: DocumentListItem[]) {

{m.docs_empty_heading()}

- {q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()} + {#if hasDateRange} + {m.docs_range_excludes_undated()} + {:else if q} + {m.docs_empty_for_term({ term: q })} + {:else} + {m.docs_empty_text()} + {/if}

+ {/if} diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 26d1d333..11f4c32d 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -128,6 +128,42 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => { }); }); +describe('SearchFilterBar – undated-only toggle (#668)', () => { + async function openAdvanced() { + const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); + await filterBtn.click(); + } + + it('renders the "Nur undatierte" toggle in the advanced row', async () => { + render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc' }); + await openAdvanced(); + await expect.element(page.getByTestId('undated-only-toggle')).toBeInTheDocument(); + }); + + it('reflects the active undated state via aria-pressed', async () => { + render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undated: true }); + await openAdvanced(); + await expect + .element(page.getByTestId('undated-only-toggle')) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls onSearchImmediate when the undated toggle is clicked', async () => { + const onSearch = vi.fn(); + const onSearchImmediate = vi.fn(); + render(SearchFilterBar, { + ...defaultProps, + onSearch, + onSearchImmediate, + sort: 'DATE', + dir: 'desc' + }); + await openAdvanced(); + await page.getByTestId('undated-only-toggle').click(); + await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0); + }); +}); + describe('SearchFilterBar – tagQ live filter', () => { it('calls onSearch when tag text changes in TagInput', async () => { vi.stubGlobal( diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index f938334a..eabfea8c 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -32,10 +32,16 @@ let sort = $state(untrack(() => data.sort || 'DATE')); let dir = $state(untrack(() => data.dir || 'desc')); let tagQ = $state(untrack(() => data.tagQ || '')); let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND')); +let undated = $state(untrack(() => data.undated ?? false)); function hasAdvancedFilters() { return ( - (data.tags?.length ?? 0) > 0 || !!data.senderId || !!data.receiverId || !!data.from || !!data.to + (data.tags?.length ?? 0) > 0 || + !!data.senderId || + !!data.receiverId || + !!data.from || + !!data.to || + !!data.undated ); } @@ -54,6 +60,7 @@ type FilterSnapshot = { dir: string; tagQ: string; tagOp: 'AND' | 'OR'; + undated: boolean; zoomFrom?: string | null; zoomTo?: string | null; }; @@ -77,6 +84,7 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte if (filters.dir) params.set('dir', filters.dir); if (filters.tagQ) params.set('tagQ', filters.tagQ); if (filters.tagOp === 'OR') params.set('tagOp', 'OR'); + if (filters.undated) params.set('undated', 'true'); if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom); if (filters.zoomTo) params.set('zoomTo', filters.zoomTo); if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage)); @@ -112,6 +120,7 @@ function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) { dir, tagQ, tagOp: tagOperator, + undated, zoomFrom, zoomTo }); @@ -136,7 +145,8 @@ function buildPageHref(targetPage: number): string { sort: data.sort || '', dir: data.dir || '', tagQ: data.tagQ || '', - tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' + tagOp: (data.tagOp as 'AND' | 'OR') || 'AND', + undated: data.undated ?? false }, targetPage ); @@ -188,7 +198,8 @@ async function editAllMatching() { sort: '', dir: '', tagQ: data.tagQ || '', - tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' + tagOp: (data.tagOp as 'AND' | 'OR') || 'AND', + undated: data.undated ?? false }); params.delete('sort'); params.delete('dir'); @@ -226,6 +237,7 @@ $effect(() => { dir = data.dir || 'desc'; tagQ = data.tagQ || ''; tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND'; + undated = data.undated ?? false; if (hasAdvancedFilters()) showAdvanced = true; }); @@ -255,6 +267,7 @@ $effect(() => { bind:dir={dir} bind:tagQ={tagQ} bind:tagOperator={tagOperator} + bind:undated={undated} initialSenderName={initialSenderName} initialReceiverName={initialReceiverName} navKey={navKey} -- 2.49.1 From a345bba74be301a2adc61b8cc913c0ed3e79bcc9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:54:35 +0200 Subject: [PATCH 08/19] test(activity): assert Chronik rows never fabricate a letter date Negative guarantee for #668: ChronikRow renders the activity timestamp (happenedAt), and ActivityFeedItemDTO carries no document-date surface, so no undated badge or "Datum unbekannt" letter-date label may appear. Pins this as a regression fixture so a future change can't quietly add a date chip to the activity feed. Refs #668 --- frontend/src/lib/activity/ChronikRow.svelte.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/lib/activity/ChronikRow.svelte.spec.ts b/frontend/src/lib/activity/ChronikRow.svelte.spec.ts index 0afccf9c..c97d8957 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikRow.svelte.spec.ts @@ -44,6 +44,17 @@ describe('ChronikRow', () => { expect(link).not.toBeNull(); }); + // --- #668 negative guarantee: Chronik never fabricates a letter date --- + it('renders the activity timestamp, not a letter date, and no undated badge', async () => { + // The row shows the relative activity time (happenedAt), never the letter's + // documentDate — ActivityFeedItemDTO carries no date surface to badge. + render(ChronikRow, { item: baseItem }); + // No undated badge is introduced into a Chronik row. + expect(document.querySelector('[data-testid="undated-badge"]')).toBeNull(); + // No fabricated "Datum unbekannt" letter-date label appears. + await expect.element(page.getByText('Datum unbekannt')).not.toBeInTheDocument(); + }); + // --- simple variant --- it('renders simple variant when count === 1 and not a mention', async () => { render(ChronikRow, { item: baseItem }); -- 2.49.1 From eacfd15f8e14bed2f9ed351ed0de70da5a7af0b6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 19:06:16 +0200 Subject: [PATCH 09/19] refactor(document): revert resolveSort to private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No test calls resolveSort directly — the sort tests assert through searchDocuments + ArgumentCaptor, so the package-private widening added no value. Narrow the API surface back to private. Refs #668 Co-Authored-By: Claude Opus 4.7 --- .../org/raddatz/familienarchiv/document/DocumentService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 43d206b7..fef6e618 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -802,7 +802,7 @@ public class DocumentService { return transcriptionBlockQueryService.getCompletionStats(docIds); } - Sort resolveSort(DocumentSort sort, String dir) { + private Sort resolveSort(DocumentSort sort, String dir) { Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC; if (sort == null || sort == DocumentSort.DATE || sort == DocumentSort.RELEVANCE) { // Undated documents (null documentDate) must order last regardless of -- 2.49.1 From caec92e7deb35878958aeb0f99777e2aadad8003 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 19:06:33 +0200 Subject: [PATCH 10/19] test(document): lock undated-stays-in-sender-group with ordered multi-sender assertions Replace the single-sender containsExactlyInAnyOrder check with a two-sender fixture and ordered containsExactly proving an undated doc stays within its sender group and never floats to the page head. Add a DESC-direction case for in-memory-path symmetry and an undated=true + sort=SENDER case capturing the Specification to prove undatedOnly is still applied on the person-sort path. Refs #668 Co-Authored-By: Claude Opus 4.7 --- .../document/DocumentServiceTest.java | 90 ++++++++++++++++--- 1 file changed, 77 insertions(+), 13 deletions(-) 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 757284ca..b0f16574 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -1668,26 +1668,90 @@ class DocumentServiceTest { // ─── searchDocuments — undated docs stay in their person group (#668) ─────── @Test - void searchDocuments_senderSort_keepsUndatedDocumentUnderItsSender() { - // Locking test: the in-memory SENDER comparator orders by sender name, not - // date, so an undated (null documentDate) letter must NOT be pulled out of - // its sender's group — it sorts by sender exactly like a dated letter. - Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build(); - Document datedFromAlice = Document.builder().id(UUID.randomUUID()).title("Dated") - .sender(alice).documentDate(LocalDate.of(1916, 6, 15)).build(); - Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated") - .sender(alice).documentDate(null).build(); + void searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead() { + // Locking test (#668): the in-memory SENDER comparator orders by sender name, + // not by date, so an undated (null documentDate) letter must stay WITHIN its + // sender's group — it must NOT float to the head of a multi-sender page. + // Two senders, each with a dated + an undated doc. ASC by "lastName firstName": + // "Adler Bob" < "Ziegler Anna", so both of Bob's docs come before both of Anna's. + // The undated doc supplied FIRST in the input proves grouping (not date) wins: + // were it ordered by date, the two undated docs would clump together at one end. + Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build(); + Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build(); + Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated") + .sender(bobAdler).documentDate(null).build(); + Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated") + .sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build(); + Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated") + .sender(annaZiegler).documentDate(null).build(); + Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated") + .sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build(); + // Input order interleaves dated/undated so a date-based regression would reorder. when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) - .thenReturn(List.of(datedFromAlice, undatedFromAlice)); + .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna)); DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED); - // Both stay together under Alice; neither is dropped or reordered by date. + // Bob's group precedes Anna's group (ASC by sender). The sort is stable, so + // within each group the input order is preserved (undatedBob, datedBob for Bob; + // datedAnna, undatedAnna for Anna). The undated docs never jump to the head and + // each stays inside its sender group — a date-based comparator would instead + // clump the two undated docs together at one end. assertThat(result.items()).extracting(DocumentListItem::title) - .containsExactlyInAnyOrder("Dated", "Undated"); - assertThat(result.items()).allMatch(item -> item.sender().getId().equals(alice.getId())); + .containsExactly("Bob undated", "Bob dated", "Anna dated", "Anna undated"); + } + + @Test + void searchDocuments_senderSort_desc_keepsUndatedInsideSenderGroupNotAtHead() { + // DESC symmetry for the in-memory path: sender order reverses ("Ziegler Anna" + // before "Adler Bob"), but the undated doc still sorts by sender, never by date, + // so it stays within its group and does not surface at the page head. + Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build(); + Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build(); + Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated") + .sender(bobAdler).documentDate(null).build(); + Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated") + .sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build(); + Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated") + .sender(annaZiegler).documentDate(null).build(); + Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated") + .sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build(); + + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) + .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna)); + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "desc", null, false, UNPAGED); + + // Anna's group precedes Bob's (DESC by sender); undated stays inside its group. + assertThat(result.items()).extracting(DocumentListItem::title) + .containsExactly("Anna dated", "Anna undated", "Bob undated", "Bob dated"); + } + + @Test + void searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification() { + // Reachable UI state: "Nur undatierte" toggled on while grouped by sender. + // The SENDER sort takes the in-memory path, but the undatedOnly predicate must + // still be composed into the Specification handed to the repository — proven by + // capturing the spec passed to findAll and confirming it filters to null dates. + Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build(); + Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated") + .sender(alice).documentDate(null).build(); + + org.mockito.ArgumentCaptor> specCaptor = + org.mockito.ArgumentCaptor.forClass(org.springframework.data.jpa.domain.Specification.class); + when(documentRepository.findAll(specCaptor.capture())) + .thenReturn(List.of(undatedFromAlice)); + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, true, UNPAGED); + + // The in-memory path queried via a Specification (built by buildSearchSpec with + // undatedOnly(true)) rather than skipping straight to a sorted findAll. + assertThat(specCaptor.getValue()).isNotNull(); + assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Undated"); } @Test -- 2.49.1 From 85372e3669818f2167e1fd706f0d40b450376413 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 19:08:41 +0200 Subject: [PATCH 11/19] fix(documents): enlarge undated badge text to text-xs for legibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Datum unbekannt" is a semantically meaningful date surface, not decorative chrome, so the 10px chip text is too small for the senior reader audience. Bump to text-xs (≥12px) per the WCAG min-legible-text guidance. Refs #668 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/document/DocumentDate.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/document/DocumentDate.svelte b/frontend/src/lib/document/DocumentDate.svelte index 937b5f7c..5db5700e 100644 --- a/frontend/src/lib/document/DocumentDate.svelte +++ b/frontend/src/lib/document/DocumentDate.svelte @@ -39,7 +39,7 @@ const showRawLine = $derived( --> Date: Wed, 27 May 2026 19:09:07 +0200 Subject: [PATCH 12/19] refactor(documents): collapse redundant span nesting in DocumentDate else branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dated branch wrapped {label} in a flex span containing a single child span — redundant nesting. Render the label directly in one span. Refs #668 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/document/DocumentDate.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/lib/document/DocumentDate.svelte b/frontend/src/lib/document/DocumentDate.svelte index 5db5700e..5b959881 100644 --- a/frontend/src/lib/document/DocumentDate.svelte +++ b/frontend/src/lib/document/DocumentDate.svelte @@ -59,9 +59,7 @@ const showRawLine = $derived( {label} {:else} - - {label} - + {label} {/if} {#if showRawLine} diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 11f4c32d..446cd046 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -162,6 +162,20 @@ describe('SearchFilterBar – undated-only toggle (#668)', () => { await page.getByTestId('undated-only-toggle').click(); await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0); }); + + it('shows the global undated count chip when undatedCount > 0', async () => { + // The count is the backend's global filtered total (#668), passed straight + // through — the chip must render it verbatim, not a page-derived number. + render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 42 }); + await openAdvanced(); + await expect.element(page.getByTestId('undated-count')).toHaveTextContent('42'); + }); + + it('hides the undated count chip when undatedCount is 0', async () => { + render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 0 }); + await openAdvanced(); + await expect.element(page.getByTestId('undated-count')).not.toBeInTheDocument(); + }); }); describe('SearchFilterBar – tagQ live filter', () => { diff --git a/frontend/src/routes/documents/+page.server.ts b/frontend/src/routes/documents/+page.server.ts index acd8f666..a4e2242b 100644 --- a/frontend/src/routes/documents/+page.server.ts +++ b/frontend/src/routes/documents/+page.server.ts @@ -85,6 +85,7 @@ export async function load({ url, fetch }) { pageNumber: 0, pageSize: PAGE_SIZE, totalPages: 0, + undatedCount: 0, q, from, to, @@ -116,6 +117,8 @@ export async function load({ url, fetch }) { pageNumber: result.data?.pageNumber ?? page, pageSize: result.data?.pageSize ?? PAGE_SIZE, totalPages: result.data?.totalPages ?? 0, + // Global undated count for the active filter, across all pages (issue #668). + undatedCount: result.data?.undatedCount ?? 0, q, from, to, diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index eabfea8c..5006d9eb 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -268,6 +268,7 @@ $effect(() => { bind:tagQ={tagQ} bind:tagOperator={tagOperator} bind:undated={undated} + undatedCount={data.undatedCount ?? 0} initialSenderName={initialSenderName} initialReceiverName={initialReceiverName} navKey={navKey} diff --git a/frontend/src/routes/documents/page.server.spec.ts b/frontend/src/routes/documents/page.server.spec.ts index 69726562..c51eb3f0 100644 --- a/frontend/src/routes/documents/page.server.spec.ts +++ b/frontend/src/routes/documents/page.server.spec.ts @@ -225,6 +225,51 @@ describe('documents page load — search params', () => { expect(result.totalElements).toBe(42); }); + it('forwards the global undatedCount from the search result (#668)', async () => { + // The backend returns the global undated total for the active filter across + // ALL pages; the loader must pass it straight through, not recompute it locally. + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { + items: [], + totalElements: 200, + pageNumber: 0, + pageSize: 50, + totalPages: 4, + undatedCount: 73 + } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ q: 'test' }), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.undatedCount).toBe(73); + }); + + it('defaults undatedCount to 0 when the search result omits it', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.undatedCount).toBe(0); + }); + it('returns filter values in the result for pre-filling the UI', async () => { const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, -- 2.49.1 From 995471082e613ef143805bc2c38c8d0d408a1d01 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 19:54:24 +0200 Subject: [PATCH 16/19] test(documents): update obsolete em-dash assertion to undated badge The "missing documentDate" test asserted the OLD bare em-dash; #668 replaced it with the "Datum unbekannt" badge via . Assert the badge text and rename the misleading test title. Refs #668 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/document/DocumentRow.svelte.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/document/DocumentRow.svelte.test.ts b/frontend/src/lib/document/DocumentRow.svelte.test.ts index 94b30a44..ca66951e 100644 --- a/frontend/src/lib/document/DocumentRow.svelte.test.ts +++ b/frontend/src/lib/document/DocumentRow.svelte.test.ts @@ -154,10 +154,11 @@ describe('DocumentRow', () => { await expect.element(page.getByTestId('doc-summary')).toBeVisible(); }); - it('renders an em-dash for missing documentDate', async () => { + it("renders 'Datum unbekannt' for a missing documentDate", async () => { render(DocumentRow, { props: { item: baseItem({ documentDate: null }) } }); - // Multiple em-dashes possible; just ensure at least one is rendered - expect(document.body.textContent).toContain('—'); + // #668: an undated document renders the "Datum unbekannt" badge (via + // ), never a bare em-dash. + await expect.element(page.getByText('Datum unbekannt').first()).toBeInTheDocument(); }); }); -- 2.49.1 From 45e63307bb732d048eea5ee0148b2bb46521c90a Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 19:54:48 +0200 Subject: [PATCH 17/19] fix(documents): give the undated count chip a self-describing a11y name A screen reader announced the bare number ("Nur undatierte 42"). Add an aria-label ("42 undatierte Dokumente") via a new i18n key and hide the purely-visual digit with aria-hidden, so the toggle + count read sensibly. Refs #668 Co-Authored-By: Claude Opus 4.7 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/SearchFilterBar.svelte | 7 ++++++- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index f2524003..ea53eb8b 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -101,6 +101,7 @@ "docs_list_unknown": "Unbekannt", "docs_group_undated": "Undatiert", "docs_filter_undated_only": "Nur undatierte", + "docs_filter_undated_count_label": "{count} undatierte Dokumente", "docs_range_excludes_undated": "Ein Datumsfilter schließt undatierte Dokumente aus, da sie keinem Zeitraum zugeordnet werden können.", "docs_group_unknown": "Unbekannt", "doc_section_who_when": "Wer & Wann", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 33e7f222..20b04d03 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -101,6 +101,7 @@ "docs_list_unknown": "Unknown", "docs_group_undated": "Undated", "docs_filter_undated_only": "Undated only", + "docs_filter_undated_count_label": "{count} undated documents", "docs_range_excludes_undated": "A date range filter excludes undated documents, because they cannot belong to any time span.", "docs_group_unknown": "Unknown", "doc_section_who_when": "Who & When", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index e8859767..de536777 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -101,6 +101,7 @@ "docs_list_unknown": "Desconocido", "docs_group_undated": "Sin fecha", "docs_filter_undated_only": "Solo sin fecha", + "docs_filter_undated_count_label": "{count} documentos sin fecha", "docs_range_excludes_undated": "Un filtro de intervalo de fechas excluye los documentos sin fecha, ya que no pueden pertenecer a ningún periodo.", "docs_group_unknown": "Desconocido", "doc_section_who_when": "Quién & Cuándo", diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index 5bec7245..2b980974 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -281,12 +281,17 @@ $effect(() => { pages (not the page slice). Stays visible regardless of the toggle state so it advertises the triage backlog size (issue #668). --> {#if undatedCount > 0} + {undatedCount}
{/if} -- 2.49.1 From b52bf609134c7e1d8bec1c43c8aedb8dfcf5c128 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 20:21:18 +0200 Subject: [PATCH 18/19] fix(document): tie-break equal-date DATE sort by title asc, not createdAt Owner decision (#668): when two documents share a meta_date, order them by title ascending instead of createdAt ascending. title is @Column(nullable=false) so it is always present, giving a deterministic, human-meaningful total order. Only the DATE-sort fast path changes; the in-memory SENDER/RECEIVER/RELEVANCE comparators are untouched. ORDER BY meta_date NULLS LAST, title ASC Tests assert title-asc tiebreaking for same-date rows in BOTH directions, with a fixture whose title order is the OPPOSITE of insertion (createdAt) order so the test fails if the tiebreaker reverts to createdAt. The integration test drives the production resolveSort against real Postgres. Refs #668 --- .../document/DocumentService.java | 5 ++- .../document/DocumentServiceTest.java | 10 +++++ ...ndatedDocumentOrderingIntegrationTest.java | 45 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) 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 b4762502..d25cf03c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -839,11 +839,12 @@ public class DocumentService { // Undated documents (null documentDate) must order last regardless of // direction — Postgres puts NULLs FIRST on ASC by default, which would // surface the undated pile at the top with no explanation (issue #668). - // The createdAt tiebreaker gives a stable total order when every row is + // The title tiebreaker gives a stable total order when every row is // null-dated (the "Nur undatierte" filter), so pagination is deterministic. + // title is @Column(nullable=false), so it is always present. return Sort.by( new Sort.Order(direction, "documentDate").nullsLast(), - Sort.Order.asc("createdAt")); + Sort.Order.asc("title")); } // SENDER and RECEIVER are sorted in-memory before this method is called return switch (sort) { 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 b0f16574..04b84fba 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -1463,6 +1463,11 @@ class DocumentServiceTest { assertThat(dateOrder).isNotNull(); assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.DESC); assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST); + // Owner-decided tiebreaker (#668): title ASC, not createdAt. + Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title"); + assertThat(tiebreak).isNotNull(); + assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC); + assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull(); } @Test @@ -1481,6 +1486,11 @@ class DocumentServiceTest { assertThat(dateOrder).isNotNull(); assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.ASC); assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST); + // Owner-decided tiebreaker (#668): title ASC, not createdAt. + Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title"); + assertThat(tiebreak).isNotNull(); + assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC); + assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull(); } @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java index e7e50d74..e1eeddc7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java @@ -63,6 +63,51 @@ class UndatedDocumentOrderingIntegrationTest { assertThat(result.get(3).getDocumentDate()).isNull(); } + @Test + void sameDate_tiebreaksByTitleAsc_notCreatedAt_forBothDirections() throws Exception { + // Owner decision (#668): equal-date rows tie-break by title ASC, NOT + // createdAt. Insert two same-date docs so that createdAt order (insertion + // order) is the OPPOSITE of title order: the first-saved doc gets the later + // title ("zzz-first"), the second-saved doc gets the earlier title + // ("aaa-second"). If the tiebreaker were still createdAt-asc the first-saved + // row would lead; because it is title-asc the "aaa-second" row must lead — + // and it must lead in BOTH ASC and DESC date directions, since the date is + // equal so only the title tiebreaker decides. + // + // The Sort under test is built by the PRODUCTION resolveSort(DATE, dir) (via + // reflection — it is private), not hand-rolled here, so this test proves the + // real Postgres ordering that production emits, on real same-date rows. + documentRepository.deleteAll(); + LocalDate sameDate = LocalDate.of(1920, 3, 3); + save("zzz-first", sameDate); // saved first → earlier createdAt + save("aaa-second", sameDate); // saved second → later createdAt + + List asc = documentRepository.findAll(resolveProductionSort("ASC")); + assertThat(asc).extracting(Document::getTitle) + .containsExactly("aaa-second", "zzz-first"); + + List desc = documentRepository.findAll(resolveProductionSort("DESC")); + assertThat(desc).extracting(Document::getTitle) + .containsExactly("aaa-second", "zzz-first"); + } + + /** + * Invokes the production {@link DocumentService#resolveSort(DocumentSort, String)} + * for the DATE sort so the integration assertions exercise the real tiebreaker + * choice rather than a sort hand-built in the test. + */ + private Sort resolveProductionSort(String dir) throws Exception { + // resolveSort is a pure function of its arguments (uses no instance state), so a + // bean instance with null collaborators is sufficient to exercise it. + var ctor = DocumentService.class.getDeclaredConstructors()[0]; + ctor.setAccessible(true); + Object[] args = new Object[ctor.getParameterCount()]; + DocumentService service = (DocumentService) ctor.newInstance(args); + var m = DocumentService.class.getDeclaredMethod("resolveSort", DocumentSort.class, String.class); + m.setAccessible(true); + return (Sort) m.invoke(service, DocumentSort.DATE, dir); + } + @Test void undatedOnly_returnsExactlyTheNullDatedRows() { List result = documentRepository.findAll(undatedOnly(true)); -- 2.49.1 From 7183d15fe597e37f25c8e47801ac3c13ac994964 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 21:04:48 +0200 Subject: [PATCH 19/19] fix(document): restore pure-text-relevance FTS fast path past undated count The global undated-count rework moved the pure-text-RELEVANCE shortcut into runSearch, where it ran after the unconditional findAllMatchingIdsByFts call. That routed pure-text relevance through the in-memory id path and returned empty match data, breaking FTS rank order and snippet/offset enrichment. Hoist the shortcut back to the top of searchDocuments so it short-circuits to findFtsPageRaw before findAllMatchingIdsByFts, while still computing the global undatedCount for all non-fast-path searches. Refs #668 Co-Authored-By: Claude Opus 4.7 --- .../document/DocumentService.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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 d25cf03c..73c60e43 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -669,6 +669,18 @@ public class DocumentService { public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) { boolean hasText = StringUtils.hasText(text); + // Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip + // findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any + // findAllMatchingIdsByFts call so the fast path is preserved. An active undated + // filter must NOT take this path: it bypasses buildSearchSpec, so the + // undatedOnly predicate would be silently dropped. By definition this path has + // no date/sender/receiver/tag/status filters, and undated documents are valid + // FTS hits already folded into the ranked page, so there is no separate undated + // count to report here. + if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) { + return relevanceSortedPageFromSql(text, pageable); + } + List rankedIds = null; if (hasText) { rankedIds = documentRepository.findAllMatchingIdsByFts(text); @@ -706,13 +718,8 @@ public class DocumentService { List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) { - // Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008). - // An active undated filter must NOT take this path: it bypasses buildSearchSpec, so the - // undatedOnly predicate would be silently dropped. - if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) { - return relevanceSortedPageFromSql(text, pageable); - } - + // The pure-text RELEVANCE fast path is handled by the caller (searchDocuments) + // before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008). Specification spec = buildSearchSpec( hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated); -- 2.49.1