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);