feat(documents): honest handling of undated documents in browse & search (Phase 6, #668) #682

Merged
marcel merged 19 commits from feature/668-undated-documents into docs/import-migration 2026-05-27 21:26:50 +02:00
29 changed files with 1077 additions and 119 deletions

View File

@@ -313,9 +313,10 @@ public class DocumentController {
@RequestParam(required = false) String tagQ, @RequestParam(required = false) String tagQ,
@RequestParam(required = false) DocumentStatus status, @RequestParam(required = false) DocumentStatus status,
@RequestParam(required = false) String tagOp, @RequestParam(required = false) String tagOp,
@RequestParam(required = false) Boolean undated,
Authentication authentication) { Authentication authentication) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator); List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) { if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_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 field") @RequestParam(required = false) DocumentSort sort,
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir, @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 = "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 // @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 // as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
// Hibernate cheerfully turns into an invalid SQL OFFSET. // 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. // defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
Pageable pageable = PageRequest.of(page, size); 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) @GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)

View File

@@ -15,24 +15,45 @@ public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageSize, int pageSize,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @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 * 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<DocumentListItem> items) { public static DocumentSearchResult of(List<DocumentListItem> items) {
int size = items.size(); 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 * Paged factory used by the service when it has a real Pageable + full match count
* (e.g. from Spring's Page&lt;T&gt; or from an in-memory sort-then-slice). * (e.g. from Spring's Page&lt;T&gt; 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<DocumentListItem> slice, Pageable pageable, long totalElements) { public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
int pageSize = pageable.getPageSize(); int pageSize = pageable.getPageSize();
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize); 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);
} }
} }

View File

@@ -171,7 +171,7 @@ public class DocumentService {
hasFts, ftsIds, null, null, hasFts, ftsIds, null, null,
filters.sender(), filters.receiver(), filters.sender(), filters.receiver(),
filters.tags(), filters.tagQ(), filters.tags(), filters.tagQ(),
filters.status(), filters.tagOperator()); filters.status(), filters.tagOperator(), false);
return documentRepository.findAll(spec).stream() return documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate) .map(Document::getDocumentDate)
.filter(Objects::nonNull) .filter(Objects::nonNull)
@@ -501,7 +501,8 @@ public class DocumentService {
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) { List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator,
boolean undated) {
boolean hasText = StringUtils.hasText(text); boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
if (hasText) { if (hasText) {
@@ -510,7 +511,7 @@ public class DocumentService {
} }
Specification<Document> spec = buildSearchSpec( Specification<Document> 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(); return documentRepository.findAll(spec).stream().map(Document::getId).toList();
} }
@@ -524,7 +525,8 @@ public class DocumentService {
LocalDate from, LocalDate to, LocalDate from, LocalDate to,
UUID sender, UUID receiver, UUID sender, UUID receiver,
List<String> tags, String tagQ, List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) { DocumentStatus status, TagOperator tagOperator,
boolean undated) {
boolean useOrLogic = tagOperator == TagOperator.OR; boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null; Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
@@ -534,7 +536,8 @@ public class DocumentService {
.and(hasReceiver(receiver)) .and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic)) .and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ)) .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) // 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> 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<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) {
boolean hasText = StringUtils.hasText(text); boolean hasText = StringUtils.hasText(text);
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008). // Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
if (isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) { // 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); return relevanceSortedPageFromSql(text, pageable);
} }
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
if (hasText) { if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(text); rankedIds = documentRepository.findAllMatchingIdsByFts(text);
// FTS matched nothing → no results and, by definition, no undated matches either.
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); 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<UUID> ftsIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
Specification<Document> 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<UUID> rankedIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> 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<Document> spec = buildSearchSpec( Specification<Document> 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. // 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 // 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) { private Sort resolveSort(DocumentSort sort, String dir) {
Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC; Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
if (sort == null || sort == DocumentSort.DATE || sort == DocumentSort.RELEVANCE) { 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 // SENDER and RECEIVER are sorted in-memory before this method is called
return switch (sort) { return switch (sort) {

View File

@@ -55,6 +55,12 @@ public class DocumentSpecifications {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); 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<Document> 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. * Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik.
* *

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.document; package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.document.DocumentSearchResult; import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentVersionSummary; import org.raddatz.familienarchiv.document.DocumentVersionSummary;
@@ -35,7 +36,9 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -73,23 +76,69 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_returns200_whenAuthenticated() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk()); .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<Boolean> 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<Boolean> 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 @Test
@WithMockUser @WithMockUser
void search_withStatusParam_passesItToService() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk()); .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 @Test
@@ -116,7 +165,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_responseContainsTotalCount() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
@@ -131,7 +180,7 @@ class DocumentControllerTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData( var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); "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( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, 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 { void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of()); 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( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, null, null, DatePrecision.UNKNOWN, null, null,
@@ -172,7 +221,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_responseExposesPagingFields() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
@@ -217,7 +266,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_passesPageRequestToService() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25")) mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
@@ -225,7 +274,7 @@ class DocumentControllerTest {
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor = org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class); 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.springframework.data.domain.Pageable pageable = captor.getValue();
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2); org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25); org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
@@ -1143,7 +1192,7 @@ class DocumentControllerTest {
void getDocumentIds_returns200_andDelegatesToService() throws Exception { void getDocumentIds_returns200_andDelegatesToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID(); 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)); .thenReturn(List.of(id));
mockMvc.perform(get("/api/documents/ids")) mockMvc.perform(get("/api/documents/ids"))
@@ -1156,13 +1205,13 @@ class DocumentControllerTest {
void getDocumentIds_passesSenderIdParamToService() throws Exception { void getDocumentIds_passesSenderIdParamToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID senderId = UUID.randomUUID(); 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()); .thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString())) mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
.andExpect(status().isOk()); .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 @Test
@@ -1172,7 +1221,7 @@ class DocumentControllerTest {
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000). // Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001); java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID()); 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); .thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids")) mockMvc.perform(get("/api/documents/ids"))

View File

@@ -123,8 +123,7 @@ class DocumentLazyLoadingTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.RECEIVER, "asc", null, DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20));
PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0); assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() -> assertThatCode(() ->
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); })) result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
@@ -139,8 +138,7 @@ class DocumentLazyLoadingTest {
assertThatCode(() -> documentService.searchDocuments( assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null, DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20)))
PageRequest.of(0, 20)))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
} }

View File

@@ -56,8 +56,7 @@ class DocumentListItemIntegrationTest {
assertThatCode(() -> documentService.searchDocuments( assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)))
PageRequest.of(0, 50)))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
} }
@@ -72,8 +71,7 @@ class DocumentListItemIntegrationTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
PageRequest.of(0, 50));
assertThat(result.totalElements()).isGreaterThan(0); assertThat(result.totalElements()).isGreaterThan(0);
DocumentListItem item = result.items().get(0); DocumentListItem item = result.items().get(0);
@@ -94,8 +92,7 @@ class DocumentListItemIntegrationTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
PageRequest.of(0, 50));
DocumentListItem item = result.items().stream() DocumentListItem item = result.items().stream()
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow(); .filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();

View File

@@ -62,8 +62,7 @@ class DocumentSearchPagedIntegrationTest {
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() { void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
PageRequest.of(0, 50));
assertThat(result.items()).hasSize(50); assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -76,8 +75,7 @@ class DocumentSearchPagedIntegrationTest {
void search_lastPartialPage_returnsRemainingItems() { void search_lastPartialPage_returnsRemainingItems() {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50));
PageRequest.of(2, 50));
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail. // Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
assertThat(result.items()).hasSize(20); assertThat(result.items()).hasSize(20);
@@ -89,8 +87,7 @@ class DocumentSearchPagedIntegrationTest {
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() { void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50));
PageRequest.of(99, 50));
assertThat(result.items()).isEmpty(); assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -103,8 +100,7 @@ class DocumentSearchPagedIntegrationTest {
// returns the correct total from a real repository fetch. // returns the correct total from a real repository fetch.
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null, DocumentSort.SENDER, "asc", null, false, PageRequest.of(1, 50));
PageRequest.of(1, 50));
assertThat(result.items()).hasSize(50); assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -112,16 +108,91 @@ class DocumentSearchPagedIntegrationTest {
assertThat(result.totalPages()).isEqualTo(3); 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 @Test
void search_differentPagesReturnDisjointSlices() { void search_differentPagesReturnDisjointSlices() {
DocumentSearchResult page0 = documentService.searchDocuments( DocumentSearchResult page0 = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
PageRequest.of(0, 50));
DocumentSearchResult page1 = documentService.searchDocuments( DocumentSearchResult page1 = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50));
PageRequest.of(1, 50));
// No document id should appear on both pages — slicing must be exclusive. // No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream() var idsOnPage0 = page0.items().stream()

View File

@@ -99,4 +99,32 @@ class DocumentSearchResultTest {
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); 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);
}
} }

View File

@@ -67,7 +67,7 @@ class DocumentServiceSortTest {
.thenReturn(new PageImpl<>(List.of(newer, older))); .thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments( 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()).hasSize(2);
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
@@ -84,7 +84,7 @@ class DocumentServiceSortTest {
.thenReturn(List.of(doc(id1))); .thenReturn(List.of(doc(id1)));
documentService.searchDocuments( 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).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString()); 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 when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments( 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); 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))); when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments( 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); assertThat(result.items().get(0).id()).isEqualTo(id1);
} }
@@ -133,7 +133,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, "Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, hugePage); DocumentSort.RELEVANCE, null, null, false, hugePage);
assertThat(result.items()).isEmpty(); assertThat(result.items()).isEmpty();
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt()); verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, "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()).hasSize(1);
assertThat(result.items().get(0).id()).isEqualTo(uuidId); assertThat(result.items().get(0).id()).isEqualTo(uuidId);
@@ -173,7 +173,7 @@ class DocumentServiceSortTest {
// sender filter is active → triggers in-memory path, not findFtsPageRaw // sender filter is active → triggers in-memory path, not findFtsPageRaw
LocalDate from = LocalDate.of(1900, 1, 1); LocalDate from = LocalDate.of(1900, 1, 1);
documentService.searchDocuments( 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, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository).findAllMatchingIdsByFts("Brief"); verify(documentRepository).findAllMatchingIdsByFts("Brief");

View File

@@ -47,6 +47,8 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; 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.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -1409,8 +1411,7 @@ class DocumentServiceTest {
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
org.springframework.data.domain.PageRequest.of(1, 50));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)); 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)); 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())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25));
org.springframework.data.domain.PageRequest.of(3, 25));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getPageNumber()).isEqualTo(3); 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)); .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, DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 50));
org.springframework.data.domain.PageRequest.of(0, 50));
assertThat(result.totalElements()).isEqualTo(120L); assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isZero(); assertThat(result.pageNumber()).isZero();
@@ -1450,6 +1449,50 @@ class DocumentServiceTest {
assertThat(result.items()).hasSize(1); // only the slice is enriched assertThat(result.items()).hasSize(1); // only the slice is enriched
} }
@Test
void searchDocuments_dateSort_DESC_ordersUndatedLast() {
ArgumentCaptor<Pageable> 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<Pageable> 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 @Test
void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() { void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() {
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class); ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
@@ -1457,8 +1500,7 @@ class DocumentServiceTest {
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(null, null, null, null, null, null, null, null,
DocumentSort.UPDATED_AT, "DESC", null, DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
org.springframework.data.domain.PageRequest.of(0, 5));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getSort()) assertThat(captor.getValue().getSort())
@@ -1482,8 +1524,7 @@ class DocumentServiceTest {
.thenReturn(all); .thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
org.springframework.data.domain.PageRequest.of(1, 50));
assertThat(result.totalElements()).isEqualTo(120L); assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1); assertThat(result.pageNumber()).isEqualTo(1);
@@ -1507,8 +1548,7 @@ class DocumentServiceTest {
.thenReturn(all); .thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50));
org.springframework.data.domain.PageRequest.of(10, 50));
assertThat(result.items()).isEmpty(); assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(30L); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .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)); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .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)); 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)); .thenReturn(List.of(withSender, noSender));
DocumentSearchResult result = documentService.searchDocuments( 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()).hasSize(2);
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender"); assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
@@ -1629,12 +1669,117 @@ class DocumentServiceTest {
.thenReturn(List.of(noReceivers, withReceiver)); .thenReturn(List.of(noReceivers, withReceiver));
DocumentSearchResult result = documentService.searchDocuments( 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) assertThat(result.items()).extracting(DocumentListItem::title)
.containsExactly("Has Receiver", "No Receivers"); .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<org.springframework.data.jpa.domain.Specification<Document>> 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 @Test
void searchDocuments_senderSort_nullLastNameSortsToEnd() { void searchDocuments_senderSort_nullLastNameSortsToEnd() {
// Without fix: null lastName produces sort key "null Smith" which compares // Without fix: null lastName produces sort key "null Smith" which compares
@@ -1651,7 +1796,7 @@ class DocumentServiceTest {
.thenReturn(List.of(docNullName, docSmith)); .thenReturn(List.of(docNullName, docSmith));
DocumentSearchResult result = documentService.searchDocuments( 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") // null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(DocumentListItem::title) assertThat(result.items()).extracting(DocumentListItem::title)
@@ -1674,7 +1819,7 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( 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); assertThat(result.items()).hasSize(1);
SearchMatchData md = result.items().get(0).matchData(); SearchMatchData md = result.items().get(0).matchData();
@@ -1688,8 +1833,7 @@ class DocumentServiceTest {
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
UNPAGED);
assertThat(result.items()).isEmpty(); assertThat(result.items()).isEmpty();
} }
@@ -1709,7 +1853,7 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( 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(); SearchMatchData md = result.items().get(0).matchData();
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
@@ -2226,7 +2370,7 @@ class DocumentServiceTest {
.thenReturn(List.of(d1, d2)); .thenReturn(List.of(d1, d2));
List<UUID> result = documentService.findIdsForFilter( List<UUID> 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()); assertThat(result).containsExactly(d1.getId(), d2.getId());
} }
@@ -2241,7 +2385,7 @@ class DocumentServiceTest {
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
documentService.findIdsForFilter( 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 // Spec built without throwing → OR branch was exercised. Coverage gain
// is in not-throwing on the OR-specific code path; the actual SQL is // 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()); when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter( List<UUID> 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(); assertThat(result).isEmpty();
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class)); verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));

View File

@@ -261,4 +261,21 @@ class DocumentSpecificationsTest {
assertThat(result).isEmpty(); assertThat(result).isEmpty();
} }
// ─── undatedOnly ──────────────────────────────────────────────────────────
@Test
void undatedOnly_false_returnsAllDocuments() {
// false → no predicate (null), so the filter is a no-op (issue #668).
List<Document> 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<Document> result = documentRepository.findAll(Specification.where(undatedOnly(true)));
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
assertThat(result).allMatch(d -> d.getDocumentDate() == null);
}
} }

View File

@@ -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<Document> 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<Document> asc = documentRepository.findAll(resolveProductionSort("ASC"));
assertThat(asc).extracting(Document::getTitle)
.containsExactly("aaa-second", "zzz-first");
List<Document> 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<Document> result = documentRepository.findAll(undatedOnly(true));
assertThat(result).hasSize(2);
assertThat(result).allMatch(d -> d.getDocumentDate() == null);
}
@Test
void undatedOnly_false_returnsAllRows() {
Specification<Document> spec = Specification.where(undatedOnly(false));
List<Document> result = documentRepository.findAll(spec);
assertThat(result).hasSize(4);
}
@Test
void dateRange_excludesUndatedRows() {
List<Document> 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<Document> spec = Specification
.where(undatedOnly(true))
.and(isBetween(LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31)));
List<Document> result = documentRepository.findAll(spec);
assertThat(result).isEmpty();
}
}

View File

@@ -100,6 +100,9 @@
"docs_list_summary": "Zusammenfassung", "docs_list_summary": "Zusammenfassung",
"docs_list_unknown": "Unbekannt", "docs_list_unknown": "Unbekannt",
"docs_group_undated": "Undatiert", "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", "docs_group_unknown": "Unbekannt",
"doc_section_who_when": "Wer & Wann", "doc_section_who_when": "Wer & Wann",
"doc_section_description": "Beschreibung", "doc_section_description": "Beschreibung",

View File

@@ -100,6 +100,9 @@
"docs_list_summary": "Summary", "docs_list_summary": "Summary",
"docs_list_unknown": "Unknown", "docs_list_unknown": "Unknown",
"docs_group_undated": "Undated", "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", "docs_group_unknown": "Unknown",
"doc_section_who_when": "Who & When", "doc_section_who_when": "Who & When",
"doc_section_description": "Description", "doc_section_description": "Description",

View File

@@ -100,6 +100,9 @@
"docs_list_summary": "Resumen", "docs_list_summary": "Resumen",
"docs_list_unknown": "Desconocido", "docs_list_unknown": "Desconocido",
"docs_group_undated": "Sin fecha", "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", "docs_group_unknown": "Desconocido",
"doc_section_who_when": "Quién & Cuándo", "doc_section_who_when": "Quién & Cuándo",
"doc_section_description": "Descripción", "doc_section_description": "Descripción",

View File

@@ -44,6 +44,17 @@ describe('ChronikRow', () => {
expect(link).not.toBeNull(); 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 --- // --- simple variant ---
it('renders simple variant when count === 1 and not a mention', async () => { it('renders simple variant when count === 1 and not a mention', async () => {
render(ChronikRow, { item: baseItem }); render(ChronikRow, { item: baseItem });

View File

@@ -28,13 +28,21 @@ const showRawLine = $derived(
</script> </script>
<span class="inline-flex flex-col"> <span class="inline-flex flex-col">
<span class="inline-flex items-center gap-1"> {#if isUnknown}
{#if isUnknown} <!--
<!-- Non-color cue (WCAG 1.4.1): a calendar-with-question glyph. The visible Neutral metadata chip (#668): an undated letter is an absence, NOT an error,
"Datum unbekannt" text is the redundant textual cue, so the icon is so the chip is neutral (text-ink-3 on bg-surface, ≥4.5:1 in both themes) —
decorative and hidden from assistive tech (per Leonie's a11y note). --> never red/amber. Matches the archive-metadata chip pattern in the row. The
non-color cue (WCAG 1.4.1) is the calendar-with-question glyph; the visible
"Datum unbekannt" text is the redundant textual cue and IS the accessibility,
so it is announced inline (never aria-hidden) while the icon is decorative.
-->
<span
data-testid="undated-badge"
class="inline-flex items-center gap-1 rounded border border-line px-1.5 py-0.5 font-sans text-xs tracking-widest text-ink-3 uppercase"
>
<svg <svg
class="h-3.5 w-3.5 shrink-0 text-ink-3" class="h-3 w-3 shrink-0 text-ink-3"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -48,9 +56,11 @@ const showRawLine = $derived(
<path d="M9 16a1.5 1.5 0 0 1 3 0c0 1-1.5 1.2-1.5 2.2" /> <path d="M9 16a1.5 1.5 0 0 1 3 0c0 1-1.5 1.2-1.5 2.2" />
<path d="M10.5 21h.01" /> <path d="M10.5 21h.01" />
</svg> </svg>
{/if} {label}
</span>
{:else}
<span>{label}</span> <span>{label}</span>
</span> {/if}
{#if showRawLine} {#if showRawLine}
<!-- Visible secondary line (WCAG 1.4.13 — not tooltip-only). raw is untrusted <!-- Visible secondary line (WCAG 1.4.13 — not tooltip-only). raw is untrusted
verbatim spreadsheet text; rendered via default Svelte interpolation, which verbatim spreadsheet text; rendered via default Svelte interpolation, which

View File

@@ -168,16 +168,12 @@ function safeTagColor(color: string | null | undefined): string {
document DETAIL page, never in list/search rows — list rows surface only the document DETAIL page, never in list/search rows — list rows surface only the
honest label to keep scan-rows compact. showRaw={false} enforces this; the honest label to keep scan-rows compact. showRaw={false} enforces this; the
DocumentListItem payload also intentionally omits metaDateRaw. --> DocumentListItem payload also intentionally omits metaDateRaw. -->
{#if doc.documentDate} <DocumentDate
<DocumentDate iso={doc.documentDate}
iso={doc.documentDate} precision={doc.metaDatePrecision}
precision={doc.metaDatePrecision} end={doc.metaDateEnd}
end={doc.metaDateEnd} showRaw={false}
showRaw={false} />
/>
{:else}
{/if}
</div> </div>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<ProgressRing percentage={item.completionPercentage} /> <ProgressRing percentage={item.completionPercentage} />
@@ -191,16 +187,15 @@ function safeTagColor(color: string | null | undefined): string {
<!-- Right column — desktop only --> <!-- Right column — desktop only -->
<div class="hidden flex-col gap-2 pl-4 font-sans text-sm text-ink-2 sm:flex sm:w-44 lg:w-56"> <div class="hidden flex-col gap-2 pl-4 font-sans text-sm text-ink-2 sm:flex sm:w-44 lg:w-56">
<div> <div>
{#if doc.documentDate} <!-- An undated document is an absence, not an error (#668): DocumentDate
<DocumentDate defensively maps a null date to the "Datum unbekannt" badge, so we
iso={doc.documentDate} always render it — never a bare em-dash fallback. -->
precision={doc.metaDatePrecision} <DocumentDate
end={doc.metaDateEnd} iso={doc.documentDate}
showRaw={false} precision={doc.metaDatePrecision}
/> end={doc.metaDateEnd}
{:else} showRaw={false}
/>
{/if}
</div> </div>
<div> <div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span> <span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>

View File

@@ -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 ────────────────────────────────────────────────────────────────── // ─── Snippet ──────────────────────────────────────────────────────────────────
describe('DocumentRow snippet', () => { describe('DocumentRow snippet', () => {

View File

@@ -154,10 +154,11 @@ describe('DocumentRow', () => {
await expect.element(page.getByTestId('doc-summary')).toBeVisible(); 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 }) } }); render(DocumentRow, { props: { item: baseItem({ documentDate: null }) } });
// Multiple em-dashes possible; just ensure at least one is rendered // #668: an undated document renders the "Datum unbekannt" badge (via
expect(document.body.textContent).toContain('—'); // <DocumentDate>), never a bare em-dash.
await expect.element(page.getByText('Datum unbekannt').first()).toBeInTheDocument();
}); });
}); });

View File

@@ -2471,6 +2471,8 @@ export interface components {
pageSize: number; pageSize: number;
/** Format: int32 */ /** Format: int32 */
totalPages: number; totalPages: number;
/** Format: int64 */
undatedCount: number;
}; };
MatchOffset: { MatchOffset: {
/** Format: int32 */ /** Format: int32 */
@@ -5083,6 +5085,8 @@ export interface operations {
dir?: string; dir?: string;
/** @description Tag operator: AND (default) or OR */ /** @description Tag operator: AND (default) or OR */
tagOp?: string; tagOp?: string;
/** @description Restrict to undated documents (meta_date IS NULL) */
undated?: boolean;
/** @description Page number (0-indexed) */ /** @description Page number (0-indexed) */
page?: number; page?: number;
/** @description Page size (max 100) */ /** @description Page size (max 100) */
@@ -5184,6 +5188,7 @@ export interface operations {
tagQ?: string; tagQ?: string;
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
tagOp?: string; tagOp?: string;
undated?: boolean;
}; };
header?: never; header?: never;
path?: never; path?: never;

View File

@@ -15,7 +15,9 @@ let {
error, error,
total = 0, total = 0,
q = '', q = '',
sort = 'DATE' sort = 'DATE',
from = '',
to = ''
}: { }: {
items: DocumentListItem[]; items: DocumentListItem[];
canWrite: boolean; canWrite: boolean;
@@ -23,8 +25,15 @@ let {
total?: number; total?: number;
q?: string; q?: string;
sort?: SortMode; sort?: SortMode;
from?: string;
to?: string;
} = $props(); } = $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(() => { const groups = $derived.by(() => {
if (sort === 'SENDER') return groupBySender(items); if (sort === 'SENDER') return groupBySender(items);
if (sort === 'RECEIVER') return groupByReceiver(items); if (sort === 'RECEIVER') return groupByReceiver(items);
@@ -119,7 +128,13 @@ function groupByReceiver(docItems: DocumentListItem[]) {
</div> </div>
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3> <h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
<p class="mt-1 font-sans text-sm text-ink-2"> <p class="mt-1 font-sans text-sm text-ink-2">
{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}
</p> </p>
<button <button
onclick={() => goto('/documents')} onclick={() => goto('/documents')}

View File

@@ -16,6 +16,7 @@ function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
title: 'Testbrief', title: 'Testbrief',
originalFilename: 'testbrief.pdf', originalFilename: 'testbrief.pdf',
documentDate: '2024-03-15', documentDate: '2024-03-15',
metaDatePrecision: 'DAY',
sender: undefined, sender: undefined,
receivers: [], receivers: [],
tags: [], tags: [],
@@ -278,3 +279,85 @@ describe('DocumentList DocumentRow delegation', () => {
await expect.element(mark).toHaveTextContent('Brief'); await expect.element(mark).toHaveTextContent('Brief');
}); });
}); });
// ─── Undated badge in person-grouped modes (#668) ────────────────────────────
describe('DocumentList undated badge in person grouping', () => {
const sender = {
id: 's1',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON' as const,
familyMember: false,
provisional: false
};
const receiver = {
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON' as const,
familyMember: false,
provisional: false
};
it('shows the undated badge on a row under SENDER grouping', async () => {
const items = [
makeItem({ id: '1', documentDate: undefined, metaDatePrecision: 'UNKNOWN', sender })
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' });
await expect.element(page.getByTestId('undated-badge').first()).toBeInTheDocument();
});
it('shows the undated badge on a row under RECEIVER grouping', async () => {
const items = [
makeItem({
id: '1',
documentDate: undefined,
metaDatePrecision: 'UNKNOWN',
receivers: [receiver]
})
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
await expect.element(page.getByTestId('undated-badge').first()).toBeInTheDocument();
});
it('keeps an undated letter under its sender, not in a synthetic undated sub-group', async () => {
const items = [
makeItem({ id: '1', documentDate: '1916-06-15', metaDatePrecision: 'DAY', sender }),
makeItem({ id: '2', documentDate: undefined, metaDatePrecision: 'UNKNOWN', sender })
];
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
// One sender card; no "Undatiert" group header inside person-grouped mode.
await expect
.element(page.getByTestId('group-header').filter({ hasText: 'Max Mustermann' }))
.toBeInTheDocument();
await expect.element(page.getByText('Undatiert')).not.toBeInTheDocument();
const cards = page.getByTestId('group-card');
await expect.element(cards.nth(1)).not.toBeInTheDocument();
});
});
// ─── Date-range / undated empty state (#668) ─────────────────────────────────
describe('DocumentList range-excludes-undated empty state', () => {
it('explains that a date range excludes undated documents when from/to active and no results', async () => {
render(DocumentList, {
...baseProps,
items: [],
total: 0,
from: '1920-01-01',
to: '1930-12-31'
});
await expect
.element(page.getByText(/Datumsfilter schließt undatierte Dokumente aus/))
.toBeInTheDocument();
});
it('shows the generic empty state when no date range is active', async () => {
render(DocumentList, { ...baseProps, items: [], total: 0 });
await expect.element(page.getByText(/Keine Dokumente/)).toBeInTheDocument();
await expect
.element(page.getByText(/Datumsfilter schließt undatierte Dokumente aus/))
.not.toBeInTheDocument();
});
});

View File

@@ -15,6 +15,8 @@ let {
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]), tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
tagQ = $bindable(''), tagQ = $bindable(''),
tagOperator = $bindable<'AND' | 'OR'>('AND'), tagOperator = $bindable<'AND' | 'OR'>('AND'),
undated = $bindable(false),
undatedCount = 0,
sort = $bindable('DATE'), sort = $bindable('DATE'),
dir = $bindable('desc'), dir = $bindable('desc'),
showAdvanced = $bindable(false), showAdvanced = $bindable(false),
@@ -35,6 +37,8 @@ let {
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[]; tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
tagQ?: string; tagQ?: string;
tagOperator?: 'AND' | 'OR'; tagOperator?: 'AND' | 'OR';
undated?: boolean;
undatedCount?: number;
sort?: string; sort?: string;
dir?: string; dir?: string;
showAdvanced?: boolean; showAdvanced?: boolean;
@@ -248,6 +252,50 @@ $effect(() => {
/> />
</div> </div>
</div> </div>
<!-- Undated-only triage toggle (#668). aria-pressed states the toggle, not a
colour; min-h-[44px] meets the senior-audience touch target (WCAG 2.5.5). -->
<div class="md:col-span-12">
<button
type="button"
data-testid="undated-only-toggle"
aria-pressed={undated}
onclick={() => {
undated = !undated;
(onSearchImmediate ?? onSearch)();
}}
class="inline-flex min-h-[44px] items-center gap-2 rounded border px-3 text-xs font-bold tracking-widest uppercase transition-colors {undated
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-muted text-ink-2 hover:bg-line'}"
>
<span
aria-hidden="true"
class="inline-flex h-4 w-4 items-center justify-center rounded-sm border {undated
? 'border-primary-fg bg-primary-fg/20'
: 'border-ink-3'}"
>
{#if undated}{/if}
</span>
{m.docs_filter_undated_only()}
<!-- Global count of undated docs matching the current filter across ALL
pages (not the page slice). Stays visible regardless of the toggle
state so it advertises the triage backlog size (issue #668). -->
{#if undatedCount > 0}
<!-- The bare number "42" reads as meaningless next to the toggle label
(a screen reader would announce "Nur undatierte 42"). The chip carries
a self-describing accessible name ("42 undatierte Dokumente") and its
purely-visual digit is hidden from assistive tech (issue #668, Leonie). -->
<span
data-testid="undated-count"
aria-label={m.docs_filter_undated_count_label({ count: undatedCount })}
class="inline-flex min-w-[1.5rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[0.65rem] leading-none tabular-nums {undated
? 'bg-primary-fg/20 text-primary-fg'
: 'bg-line text-ink-2'}"
><span aria-hidden="true">{undatedCount}</span></span
>
{/if}
</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -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', () => { describe('SearchFilterBar tagQ live filter', () => {
it('calls onSearch when tag text changes in TagInput', async () => { it('calls onSearch when tag text changes in TagInput', async () => {
vi.stubGlobal( vi.stubGlobal(

View File

@@ -46,6 +46,8 @@ export async function load({ url, fetch }) {
: 'desc'; : 'desc';
const tagQ = url.searchParams.get('tagQ') || ''; const tagQ = url.searchParams.get('tagQ') || '';
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND'; 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 page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
const api = createApiClient(fetch); const api = createApiClient(fetch);
@@ -66,6 +68,7 @@ export async function load({ url, fetch }) {
tag: tags.length ? tags : undefined, tag: tags.length ? tags : undefined,
tagQ: tagQ && !tags.length ? tagQ : undefined, tagQ: tagQ && !tags.length ? tagQ : undefined,
tagOp: tagOp === 'OR' ? 'OR' : undefined, tagOp: tagOp === 'OR' ? 'OR' : undefined,
undated: undated || undefined,
sort, sort,
dir: dir || undefined, dir: dir || undefined,
page, page,
@@ -82,6 +85,7 @@ export async function load({ url, fetch }) {
pageNumber: 0, pageNumber: 0,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
totalPages: 0, totalPages: 0,
undatedCount: 0,
q, q,
from, from,
to, to,
@@ -94,6 +98,7 @@ export async function load({ url, fetch }) {
dir, dir,
tagQ, tagQ,
tagOp, tagOp,
undated,
error: 'Daten konnten nicht geladen werden.' as string | null error: 'Daten konnten nicht geladen werden.' as string | null
}; };
} }
@@ -112,6 +117,8 @@ export async function load({ url, fetch }) {
pageNumber: result.data?.pageNumber ?? page, pageNumber: result.data?.pageNumber ?? page,
pageSize: result.data?.pageSize ?? PAGE_SIZE, pageSize: result.data?.pageSize ?? PAGE_SIZE,
totalPages: result.data?.totalPages ?? 0, totalPages: result.data?.totalPages ?? 0,
// Global undated count for the active filter, across all pages (issue #668).
undatedCount: result.data?.undatedCount ?? 0,
q, q,
from, from,
to, to,
@@ -124,6 +131,7 @@ export async function load({ url, fetch }) {
dir, dir,
tagQ, tagQ,
tagOp, tagOp,
undated,
error: errorMessage error: errorMessage
}; };
} }

View File

@@ -32,10 +32,16 @@ let sort = $state(untrack(() => data.sort || 'DATE'));
let dir = $state(untrack(() => data.dir || 'desc')); let dir = $state(untrack(() => data.dir || 'desc'));
let tagQ = $state(untrack(() => data.tagQ || '')); let tagQ = $state(untrack(() => data.tagQ || ''));
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND')); let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
let undated = $state(untrack(() => data.undated ?? false));
function hasAdvancedFilters() { function hasAdvancedFilters() {
return ( 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; dir: string;
tagQ: string; tagQ: string;
tagOp: 'AND' | 'OR'; tagOp: 'AND' | 'OR';
undated: boolean;
zoomFrom?: string | null; zoomFrom?: string | null;
zoomTo?: 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.dir) params.set('dir', filters.dir);
if (filters.tagQ) params.set('tagQ', filters.tagQ); if (filters.tagQ) params.set('tagQ', filters.tagQ);
if (filters.tagOp === 'OR') params.set('tagOp', 'OR'); 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.zoomFrom) params.set('zoomFrom', filters.zoomFrom);
if (filters.zoomTo) params.set('zoomTo', filters.zoomTo); if (filters.zoomTo) params.set('zoomTo', filters.zoomTo);
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage)); if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
@@ -112,6 +120,7 @@ function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) {
dir, dir,
tagQ, tagQ,
tagOp: tagOperator, tagOp: tagOperator,
undated,
zoomFrom, zoomFrom,
zoomTo zoomTo
}); });
@@ -136,7 +145,8 @@ function buildPageHref(targetPage: number): string {
sort: data.sort || '', sort: data.sort || '',
dir: data.dir || '', dir: data.dir || '',
tagQ: data.tagQ || '', tagQ: data.tagQ || '',
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
undated: data.undated ?? false
}, },
targetPage targetPage
); );
@@ -188,7 +198,8 @@ async function editAllMatching() {
sort: '', sort: '',
dir: '', dir: '',
tagQ: data.tagQ || '', 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('sort');
params.delete('dir'); params.delete('dir');
@@ -226,6 +237,7 @@ $effect(() => {
dir = data.dir || 'desc'; dir = data.dir || 'desc';
tagQ = data.tagQ || ''; tagQ = data.tagQ || '';
tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND'; tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND';
undated = data.undated ?? false;
if (hasAdvancedFilters()) showAdvanced = true; if (hasAdvancedFilters()) showAdvanced = true;
}); });
</script> </script>
@@ -255,6 +267,8 @@ $effect(() => {
bind:dir={dir} bind:dir={dir}
bind:tagQ={tagQ} bind:tagQ={tagQ}
bind:tagOperator={tagOperator} bind:tagOperator={tagOperator}
bind:undated={undated}
undatedCount={data.undatedCount ?? 0}
initialSenderName={initialSenderName} initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName} initialReceiverName={initialReceiverName}
navKey={navKey} navKey={navKey}
@@ -343,6 +357,8 @@ $effect(() => {
canWrite={data.canWrite} canWrite={data.canWrite}
error={data.error} error={data.error}
sort={sort} sort={sort}
from={data.from}
to={data.to}
/> />
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} /> <Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />

View File

@@ -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 () => { it('returns items and total from the search result', async () => {
const item = { const item = {
document: { id: 'd1' }, document: { id: 'd1' },
@@ -125,6 +225,51 @@ describe('documents page load — search params', () => {
expect(result.totalElements).toBe(42); 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 () => { it('returns filter values in the result for pre-filling the UI', async () => {
const mockGet = vi.fn().mockResolvedValue({ const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 }, response: { ok: true, status: 200 },