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/DocumentSearchResult.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSearchResult.java index b04f7fa2..0ce1758a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSearchResult.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSearchResult.java @@ -15,24 +15,45 @@ public record DocumentSearchResult( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pageSize, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - int totalPages + int totalPages, + /** + * Total number of undated documents (meta_date IS NULL) matching the current + * filter context (q/tags/sender/receiver/status) across ALL pages — not the + * undated rows on the current page. Computed independently of the "Nur + * undatierte" toggle so it never collapses to the page slice (issue #668). + */ + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + long undatedCount ) { /** * Single-page convenience factory used by empty-result shortcuts and by tests that - * don't care about paging. Treats the whole list as page 0 of itself. + * don't care about paging. Treats the whole list as page 0 of itself. The undated + * count defaults to 0 — the service overlays the real global count via + * {@link #withUndatedCount(long)} before returning. */ public static DocumentSearchResult of(List items) { int size = items.size(); - return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1); + return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1, 0L); } /** * Paged factory used by the service when it has a real Pageable + full match count - * (e.g. from Spring's Page<T> or from an in-memory sort-then-slice). + * (e.g. from Spring's Page<T> or from an in-memory sort-then-slice). The undated + * count defaults to 0 — the service overlays the real global count via + * {@link #withUndatedCount(long)} before returning. */ public static DocumentSearchResult paged(List slice, Pageable pageable, long totalElements) { int pageSize = pageable.getPageSize(); int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize); - return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages); + return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages, 0L); + } + + /** + * Returns a copy with the global undated count overlaid, leaving every other + * field untouched. Lets the service compute the count once and attach it to + * whichever result shape the search path produced. + */ + public DocumentSearchResult withUndatedCount(long undatedCount) { + return new DocumentSearchResult(items, totalElements, pageNumber, pageSize, totalPages, undatedCount); } } 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..73c60e43 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,22 +666,62 @@ 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)) { + // 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); + // FTS matched nothing → no results and, by definition, no undated matches either. if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); } + // Global undated count for the current filter (q/tags/sender/receiver/status), + // forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so + // it never collapses to the page slice and never double-counts (issue #668). + long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator); + + return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable) + .withUndatedCount(undatedCount); + } + + /** + * Counts every undated document (meta_date IS NULL) matching the active filter, + * across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec} + * with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status. + * A {@code from}/{@code to} range excludes undated rows by the collision rule (#668), + * so the count is legitimately 0 inside a date range. + */ + private long countUndatedForFilter(boolean hasText, List ftsIds, + LocalDate from, LocalDate to, UUID sender, UUID receiver, + List tags, String tagQ, DocumentStatus status, TagOperator tagOperator) { + Specification undatedSpec = buildSearchSpec( + hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true); + return documentRepository.count(undatedSpec); + } + + /** The original search dispatch — produces the page slice + totals, sans undated count. */ + private DocumentSearchResult runSearch(String text, boolean hasText, List rankedIds, + LocalDate from, LocalDate to, UUID sender, UUID receiver, + List tags, String tagQ, DocumentStatus status, + DocumentSort sort, String dir, TagOperator tagOperator, + boolean undated, Pageable 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); + 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 @@ -800,7 +843,15 @@ public class DocumentService { 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) { - 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 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("title")); } // SENDER and RECEIVER are sorted in-memory before this method is called return switch (sort) { 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/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..3d65cbac 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); @@ -112,16 +108,91 @@ class DocumentSearchPagedIntegrationTest { assertThat(result.totalPages()).isEqualTo(3); } + @Test + void search_undatedCount_isGlobalFilteredTotal_notPageSlice() { + // Seed 70 undated docs on top of the 120 dated ones. With a 50-per-page + // window the undated rows span multiple pages, so a page-local count could + // never exceed 50 — the global count must be the full 70 (issue #668). + int undatedTotal = 70; + for (int i = 0; i < undatedTotal; i++) { + documentRepository.save(Document.builder() + .title("Undatiert-" + String.format("%03d", i)) + .originalFilename("undatiert-" + i + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(DatePrecision.UNKNOWN) + .documentDate(null) + .build()); + } + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); + + // Global undated count is the full undated total, independent of page size. + assertThat(result.undatedCount()).isEqualTo(undatedTotal); + // Total matches both dated + undated (no undated-only filter applied). + assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE + undatedTotal); + // The first DATE-DESC page is all dated rows (nulls last), so a page-local + // tally would report 0 undated — proving the count is not page-derived. + assertThat(result.items()).allMatch(item -> item.documentDate() != null); + } + + @Test + void search_undatedCount_ignoresUndatedOnlyToggle() { + // The "Nur undatierte" toggle must not skew the count: whether undated=true or + // false, the global undated count for the same filter is identical (issue #668). + int undatedTotal = 12; + for (int i = 0; i < undatedTotal; i++) { + documentRepository.save(Document.builder() + .title("U-" + i) + .originalFilename("u-" + i + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(DatePrecision.UNKNOWN) + .documentDate(null) + .build()); + } + + DocumentSearchResult unfiltered = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); + DocumentSearchResult undatedOnly = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50)); + + assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal); + assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal); + } + + @Test + void search_undatedCount_isZero_insideDateRange() { + // A from/to range excludes undated rows by the collision rule (#668), so the + // global undated count inside a range is legitimately 0 even when undated docs exist. + for (int i = 0; i < 5; i++) { + documentRepository.save(Document.builder() + .title("U-range-" + i) + .originalFilename("u-range-" + i + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(DatePrecision.UNKNOWN) + .documentDate(null) + .build()); + } + + DocumentSearchResult result = documentService.searchDocuments( + null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31), + null, null, null, null, null, + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); + + assertThat(result.undatedCount()).isZero(); + } + @Test 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/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index ca4c77f5..b9b5d38d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -99,4 +99,32 @@ class DocumentSearchResultTest { assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); } } + + @Test + void undatedCount_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException { + Schema schema = DocumentSearchResult.class.getDeclaredField("undatedCount").getAnnotation(Schema.class); + assertThat(schema).isNotNull(); + assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); + } + + @Test + void factories_default_undatedCount_to_zero() { + assertThat(DocumentSearchResult.of(List.of()).undatedCount()).isZero(); + assertThat(DocumentSearchResult.paged(List.of(), PageRequest.of(0, 50), 0L).undatedCount()).isZero(); + } + + @Test + void withUndatedCount_overlays_count_and_preserves_other_fields() { + DocumentSearchResult base = DocumentSearchResult.paged( + List.of(item(UUID.randomUUID())), PageRequest.of(1, 50), 120L); + + DocumentSearchResult withCount = base.withUndatedCount(7L); + + assertThat(withCount.undatedCount()).isEqualTo(7L); + assertThat(withCount.items()).isEqualTo(base.items()); + assertThat(withCount.totalElements()).isEqualTo(120L); + assertThat(withCount.pageNumber()).isEqualTo(1); + assertThat(withCount.pageSize()).isEqualTo(50); + assertThat(withCount.totalPages()).isEqualTo(3); + } } 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 658d4c31..04b84fba 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(); @@ -1450,6 +1449,50 @@ 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, 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"); + 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 + 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, 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"); + 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 void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() { ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); @@ -1457,8 +1500,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()) @@ -1482,8 +1524,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); @@ -1507,8 +1548,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); @@ -1521,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, 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)); } @@ -1531,7 +1571,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)); } @@ -1609,7 +1649,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"); @@ -1629,12 +1669,117 @@ 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_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(undatedBob, datedAnna, datedBob, undatedAnna)); + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED); + + // 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) + .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 + 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 @@ -1651,7 +1796,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) @@ -1674,7 +1819,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(); @@ -1688,8 +1833,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(); } @@ -1709,7 +1853,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"); @@ -2226,7 +2370,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()); } @@ -2241,7 +2385,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 @@ -2254,7 +2398,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)); 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..e1eeddc7 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java @@ -0,0 +1,149 @@ +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 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)); + + 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(); + } +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7358637a..ea53eb8b 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -100,6 +100,9 @@ "docs_list_summary": "Zusammenfassung", "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", "doc_section_description": "Beschreibung", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 77097540..20b04d03 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -100,6 +100,9 @@ "docs_list_summary": "Summary", "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", "doc_section_description": "Description", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 47906c0c..de536777 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -100,6 +100,9 @@ "docs_list_summary": "Resumen", "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", "doc_section_description": "Descripción", 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 }); diff --git a/frontend/src/lib/document/DocumentDate.svelte b/frontend/src/lib/document/DocumentDate.svelte index a3539c31..5b959881 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} + + {:else} {label} - + {/if} {#if showRawLine} - {#if doc.documentDate} - - {:else} - — - {/if} +
@@ -191,16 +187,15 @@ function safeTagColor(color: string | null | undefined): string {
+ + +
+ +
{/if} diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 26d1d333..446cd046 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -128,6 +128,56 @@ 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); + }); + + 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', () => { it('calls onSearch when tag text changes in TagInput', async () => { vi.stubGlobal( diff --git a/frontend/src/routes/documents/+page.server.ts b/frontend/src/routes/documents/+page.server.ts index 16186611..a4e2242b 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, @@ -82,6 +85,7 @@ export async function load({ url, fetch }) { pageNumber: 0, pageSize: PAGE_SIZE, totalPages: 0, + undatedCount: 0, q, from, to, @@ -94,6 +98,7 @@ export async function load({ url, fetch }) { dir, tagQ, tagOp, + undated, error: 'Daten konnten nicht geladen werden.' as string | null }; } @@ -112,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, @@ -124,6 +131,7 @@ export async function load({ url, fetch }) { dir, tagQ, tagOp, + undated, error: errorMessage }; } diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index d2f05de5..5006d9eb 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,8 @@ $effect(() => { bind:dir={dir} bind:tagQ={tagQ} bind:tagOperator={tagOperator} + bind:undated={undated} + undatedCount={data.undatedCount ?? 0} initialSenderName={initialSenderName} initialReceiverName={initialReceiverName} navKey={navKey} @@ -343,6 +357,8 @@ $effect(() => { canWrite={data.canWrite} error={data.error} sort={sort} + from={data.from} + to={data.to} /> diff --git a/frontend/src/routes/documents/page.server.spec.ts b/frontend/src/routes/documents/page.server.spec.ts index 2ed33a86..c51eb3f0 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' }, @@ -125,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 },