feat(documents): honest handling of undated documents in browse & search (Phase 6, #668) #682
@@ -313,9 +313,10 @@ public class DocumentController {
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@RequestParam(required = false) DocumentStatus status,
|
||||
@RequestParam(required = false) String tagOp,
|
||||
@RequestParam(required = false) Boolean undated,
|
||||
Authentication authentication) {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
List<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) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
||||
@@ -375,6 +376,7 @@ public class DocumentController {
|
||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
||||
@Parameter(description = "Restrict to undated documents (meta_date IS NULL)") @RequestParam(required = false) Boolean undated,
|
||||
// @Max on page guards against overflow when pageable.getOffset() is computed
|
||||
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
||||
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
||||
@@ -387,7 +389,7 @@ public class DocumentController {
|
||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, Boolean.TRUE.equals(undated), pageable));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
|
||||
@@ -15,24 +15,45 @@ public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int pageSize,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int totalPages
|
||||
int totalPages,
|
||||
/**
|
||||
* Total number of undated documents (meta_date IS NULL) matching the current
|
||||
* filter context (q/tags/sender/receiver/status) across ALL pages — not the
|
||||
* undated rows on the current page. Computed independently of the "Nur
|
||||
* undatierte" toggle so it never collapses to the page slice (issue #668).
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long undatedCount
|
||||
) {
|
||||
/**
|
||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||
* don't care about paging. Treats the whole list as page 0 of itself. The undated
|
||||
* count defaults to 0 — the service overlays the real global count via
|
||||
* {@link #withUndatedCount(long)} before returning.
|
||||
*/
|
||||
public static DocumentSearchResult of(List<DocumentListItem> items) {
|
||||
int size = items.size();
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged factory used by the service when it has a real Pageable + full match count
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice). The undated
|
||||
* count defaults to 0 — the service overlays the real global count via
|
||||
* {@link #withUndatedCount(long)} before returning.
|
||||
*/
|
||||
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
|
||||
int pageSize = pageable.getPageSize();
|
||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the global undated count overlaid, leaving every other
|
||||
* field untouched. Lets the service compute the count once and attach it to
|
||||
* whichever result shape the search path produced.
|
||||
*/
|
||||
public DocumentSearchResult withUndatedCount(long undatedCount) {
|
||||
return new DocumentSearchResult(items, totalElements, pageNumber, pageSize, totalPages, undatedCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ public class DocumentService {
|
||||
hasFts, ftsIds, null, null,
|
||||
filters.sender(), filters.receiver(),
|
||||
filters.tags(), filters.tagQ(),
|
||||
filters.status(), filters.tagOperator());
|
||||
filters.status(), filters.tagOperator(), false);
|
||||
return documentRepository.findAll(spec).stream()
|
||||
.map(Document::getDocumentDate)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -501,7 +501,8 @@ public class DocumentService {
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<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);
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
@@ -510,7 +511,7 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -524,7 +525,8 @@ public class DocumentService {
|
||||
LocalDate from, LocalDate to,
|
||||
UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ,
|
||||
DocumentStatus status, TagOperator tagOperator) {
|
||||
DocumentStatus status, TagOperator tagOperator,
|
||||
boolean undated) {
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
||||
@@ -534,7 +536,8 @@ public class DocumentService {
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
.and(hasStatus(status))
|
||||
.and(undatedOnly(undated));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -663,22 +666,62 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<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);
|
||||
|
||||
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
|
||||
if (isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
||||
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
|
||||
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
|
||||
// findAllMatchingIdsByFts call so the fast path is preserved. An active undated
|
||||
// filter must NOT take this path: it bypasses buildSearchSpec, so the
|
||||
// undatedOnly predicate would be silently dropped. By definition this path has
|
||||
// no date/sender/receiver/tag/status filters, and undated documents are valid
|
||||
// FTS hits already folded into the ranked page, so there is no separate undated
|
||||
// count to report here.
|
||||
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
||||
return relevanceSortedPageFromSql(text, pageable);
|
||||
}
|
||||
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
||||
// FTS matched nothing → no results and, by definition, no undated matches either.
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
// Global undated count for the current filter (q/tags/sender/receiver/status),
|
||||
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
|
||||
// it never collapses to the page slice and never double-counts (issue #668).
|
||||
long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||
|
||||
return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable)
|
||||
.withUndatedCount(undatedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts every undated document (meta_date IS NULL) matching the active filter,
|
||||
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
|
||||
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
|
||||
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
|
||||
* so the count is legitimately 0 inside a date range.
|
||||
*/
|
||||
private long countUndatedForFilter(boolean hasText, List<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(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
||||
|
||||
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
|
||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||
@@ -800,7 +843,15 @@ public class DocumentService {
|
||||
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||
Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
if (sort == null || sort == DocumentSort.DATE || sort == DocumentSort.RELEVANCE) {
|
||||
return Sort.by(direction, "documentDate");
|
||||
// Undated documents (null documentDate) must order last regardless of
|
||||
// direction — Postgres puts NULLs FIRST on ASC by default, which would
|
||||
// surface the undated pile at the top with no explanation (issue #668).
|
||||
// The title tiebreaker gives a stable total order when every row is
|
||||
// null-dated (the "Nur undatierte" filter), so pagination is deterministic.
|
||||
// title is @Column(nullable=false), so it is always present.
|
||||
return Sort.by(
|
||||
new Sort.Order(direction, "documentDate").nullsLast(),
|
||||
Sort.Order.asc("title"));
|
||||
}
|
||||
// SENDER and RECEIVER are sorted in-memory before this method is called
|
||||
return switch (sort) {
|
||||
|
||||
@@ -55,6 +55,12 @@ public class DocumentSpecifications {
|
||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||
}
|
||||
|
||||
// Filtert auf undatierte Dokumente (meta_date IS NULL) — für die "Nur undatierte"-Triage.
|
||||
// false → kein Prädikat (no-op), true → documentDate IS NULL (issue #668).
|
||||
public static Specification<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.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.document.DocumentVersionSummary;
|
||||
@@ -35,7 +36,9 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -73,23 +76,69 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception {
|
||||
// The read GET must stay reachable for READ_ALL users — guards against a
|
||||
// future refactor accidentally write-guarding the undated triage path (#668).
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedTrue_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception {
|
||||
ArgumentCaptor<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
|
||||
@WithMockUser
|
||||
void search_withStatusParam_passesItToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,7 +165,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseContainsTotalCount() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -131,7 +180,7 @@ class DocumentControllerTest {
|
||||
UUID docId = UUID.randomUUID();
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||
DatePrecision.UNKNOWN, null, null,
|
||||
@@ -150,7 +199,7 @@ class DocumentControllerTest {
|
||||
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||
DatePrecision.UNKNOWN, null, null,
|
||||
@@ -172,7 +221,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseExposesPagingFields() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -217,7 +266,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_passesPageRequestToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||
@@ -225,7 +274,7 @@ class DocumentControllerTest {
|
||||
|
||||
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), captor.capture());
|
||||
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||
@@ -1143,7 +1192,7 @@ class DocumentControllerTest {
|
||||
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(List.of(id));
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
@@ -1156,13 +1205,13 @@ class DocumentControllerTest {
|
||||
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID senderId = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
|
||||
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1172,7 +1221,7 @@ class DocumentControllerTest {
|
||||
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
||||
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
||||
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(tooMany);
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
|
||||
@@ -123,8 +123,7 @@ class DocumentLazyLoadingTest {
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.RECEIVER, "asc", null,
|
||||
PageRequest.of(0, 20));
|
||||
DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20));
|
||||
assertThat(result.totalElements()).isGreaterThan(0);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
|
||||
@@ -139,8 +138,7 @@ class DocumentLazyLoadingTest {
|
||||
|
||||
assertThatCode(() -> documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.SENDER, "asc", null,
|
||||
PageRequest.of(0, 20)))
|
||||
DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,7 @@ class DocumentListItemIntegrationTest {
|
||||
|
||||
assertThatCode(() -> documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50)))
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@@ -72,8 +71,7 @@ class DocumentListItemIntegrationTest {
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isGreaterThan(0);
|
||||
DocumentListItem item = result.items().get(0);
|
||||
@@ -94,8 +92,7 @@ class DocumentListItemIntegrationTest {
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
DocumentListItem item = result.items().stream()
|
||||
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();
|
||||
|
||||
@@ -62,8 +62,7 @@ class DocumentSearchPagedIntegrationTest {
|
||||
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
@@ -76,8 +75,7 @@ class DocumentSearchPagedIntegrationTest {
|
||||
void search_lastPartialPage_returnsRemainingItems() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(2, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50));
|
||||
|
||||
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||
assertThat(result.items()).hasSize(20);
|
||||
@@ -89,8 +87,7 @@ class DocumentSearchPagedIntegrationTest {
|
||||
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(99, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
@@ -103,8 +100,7 @@ class DocumentSearchPagedIntegrationTest {
|
||||
// returns the correct total from a real repository fetch.
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.SENDER, "asc", null,
|
||||
PageRequest.of(1, 50));
|
||||
DocumentSort.SENDER, "asc", null, false, PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
@@ -112,16 +108,91 @@ class DocumentSearchPagedIntegrationTest {
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_isGlobalFilteredTotal_notPageSlice() {
|
||||
// Seed 70 undated docs on top of the 120 dated ones. With a 50-per-page
|
||||
// window the undated rows span multiple pages, so a page-local count could
|
||||
// never exceed 50 — the global count must be the full 70 (issue #668).
|
||||
int undatedTotal = 70;
|
||||
for (int i = 0; i < undatedTotal; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Undatiert-" + String.format("%03d", i))
|
||||
.originalFilename("undatiert-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
// Global undated count is the full undated total, independent of page size.
|
||||
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
|
||||
// Total matches both dated + undated (no undated-only filter applied).
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE + undatedTotal);
|
||||
// The first DATE-DESC page is all dated rows (nulls last), so a page-local
|
||||
// tally would report 0 undated — proving the count is not page-derived.
|
||||
assertThat(result.items()).allMatch(item -> item.documentDate() != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_ignoresUndatedOnlyToggle() {
|
||||
// The "Nur undatierte" toggle must not skew the count: whether undated=true or
|
||||
// false, the global undated count for the same filter is identical (issue #668).
|
||||
int undatedTotal = 12;
|
||||
for (int i = 0; i < undatedTotal; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("U-" + i)
|
||||
.originalFilename("u-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult unfiltered = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
DocumentSearchResult undatedOnly = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
|
||||
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_isZero_insideDateRange() {
|
||||
// A from/to range excludes undated rows by the collision rule (#668), so the
|
||||
// global undated count inside a range is legitimately 0 even when undated docs exist.
|
||||
for (int i = 0; i < 5; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("U-range-" + i)
|
||||
.originalFilename("u-range-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
|
||||
null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.undatedCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(1, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50));
|
||||
|
||||
// No document id should appear on both pages — slicing must be exclusive.
|
||||
var idsOnPage0 = page0.items().stream()
|
||||
|
||||
@@ -99,4 +99,32 @@ class DocumentSearchResultTest {
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedCount_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("undatedCount").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void factories_default_undatedCount_to_zero() {
|
||||
assertThat(DocumentSearchResult.of(List.of()).undatedCount()).isZero();
|
||||
assertThat(DocumentSearchResult.paged(List.of(), PageRequest.of(0, 50), 0L).undatedCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withUndatedCount_overlays_count_and_preserves_other_fields() {
|
||||
DocumentSearchResult base = DocumentSearchResult.paged(
|
||||
List.of(item(UUID.randomUUID())), PageRequest.of(1, 50), 120L);
|
||||
|
||||
DocumentSearchResult withCount = base.withUndatedCount(7L);
|
||||
|
||||
assertThat(withCount.undatedCount()).isEqualTo(7L);
|
||||
assertThat(withCount.items()).isEqualTo(base.items());
|
||||
assertThat(withCount.totalElements()).isEqualTo(120L);
|
||||
assertThat(withCount.pageNumber()).isEqualTo(1);
|
||||
assertThat(withCount.pageSize()).isEqualTo(50);
|
||||
assertThat(withCount.totalPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, false, PAGE);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
|
||||
@@ -84,7 +84,7 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(List.of(doc(id1)));
|
||||
|
||||
documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
|
||||
|
||||
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
|
||||
@@ -102,7 +102,7 @@ class DocumentServiceSortTest {
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
|
||||
|
||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null, false, PAGE);
|
||||
|
||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class DocumentServiceSortTest {
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null,
|
||||
DocumentSort.RELEVANCE, null, null, hugePage);
|
||||
DocumentSort.RELEVANCE, null, null, false, hugePage);
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null,
|
||||
DocumentSort.RELEVANCE, null, null, PAGE);
|
||||
DocumentSort.RELEVANCE, null, null, false, PAGE);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
|
||||
@@ -173,7 +173,7 @@ class DocumentServiceSortTest {
|
||||
// sender filter is active → triggers in-memory path, not findFtsPageRaw
|
||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||
documentService.searchDocuments(
|
||||
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
|
||||
|
||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||
verify(documentRepository).findAllMatchingIdsByFts("Brief");
|
||||
|
||||
@@ -47,6 +47,8 @@ import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -1409,8 +1411,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
@@ -1423,8 +1424,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(3, 25));
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||
@@ -1440,8 +1440,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
@@ -1450,6 +1449,50 @@ class DocumentServiceTest {
|
||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_dateSort_DESC_ordersUndatedLast() {
|
||||
ArgumentCaptor<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
|
||||
void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() {
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
@@ -1457,8 +1500,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
DocumentSort.UPDATED_AT, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getSort())
|
||||
@@ -1482,8 +1524,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
@@ -1507,8 +1548,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(30L);
|
||||
@@ -1521,7 +1561,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, false, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
@@ -1531,7 +1571,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
@@ -1609,7 +1649,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(withSender, noSender));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
||||
@@ -1629,12 +1669,117 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(noReceivers, withReceiver));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — undated docs stay in their person group (#668) ───────
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead() {
|
||||
// Locking test (#668): the in-memory SENDER comparator orders by sender name,
|
||||
// not by date, so an undated (null documentDate) letter must stay WITHIN its
|
||||
// sender's group — it must NOT float to the head of a multi-sender page.
|
||||
// Two senders, each with a dated + an undated doc. ASC by "lastName firstName":
|
||||
// "Adler Bob" < "Ziegler Anna", so both of Bob's docs come before both of Anna's.
|
||||
// The undated doc supplied FIRST in the input proves grouping (not date) wins:
|
||||
// were it ordered by date, the two undated docs would clump together at one end.
|
||||
Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build();
|
||||
Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build();
|
||||
Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated")
|
||||
.sender(bobAdler).documentDate(null).build();
|
||||
Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated")
|
||||
.sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build();
|
||||
Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated")
|
||||
.sender(annaZiegler).documentDate(null).build();
|
||||
Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated")
|
||||
.sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build();
|
||||
|
||||
// Input order interleaves dated/undated so a date-based regression would reorder.
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
||||
|
||||
// Bob's group precedes Anna's group (ASC by sender). The sort is stable, so
|
||||
// within each group the input order is preserved (undatedBob, datedBob for Bob;
|
||||
// datedAnna, undatedAnna for Anna). The undated docs never jump to the head and
|
||||
// each stays inside its sender group — a date-based comparator would instead
|
||||
// clump the two undated docs together at one end.
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
.containsExactly("Bob undated", "Bob dated", "Anna dated", "Anna undated");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_desc_keepsUndatedInsideSenderGroupNotAtHead() {
|
||||
// DESC symmetry for the in-memory path: sender order reverses ("Ziegler Anna"
|
||||
// before "Adler Bob"), but the undated doc still sorts by sender, never by date,
|
||||
// so it stays within its group and does not surface at the page head.
|
||||
Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build();
|
||||
Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build();
|
||||
Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated")
|
||||
.sender(bobAdler).documentDate(null).build();
|
||||
Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated")
|
||||
.sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build();
|
||||
Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated")
|
||||
.sender(annaZiegler).documentDate(null).build();
|
||||
Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated")
|
||||
.sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build();
|
||||
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "desc", null, false, UNPAGED);
|
||||
|
||||
// Anna's group precedes Bob's (DESC by sender); undated stays inside its group.
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
.containsExactly("Anna dated", "Anna undated", "Bob undated", "Bob dated");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification() {
|
||||
// Reachable UI state: "Nur undatierte" toggled on while grouped by sender.
|
||||
// The SENDER sort takes the in-memory path, but the undatedOnly predicate must
|
||||
// still be composed into the Specification handed to the repository — proven by
|
||||
// capturing the spec passed to findAll and confirming it filters to null dates.
|
||||
Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build();
|
||||
Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated")
|
||||
.sender(alice).documentDate(null).build();
|
||||
|
||||
org.mockito.ArgumentCaptor<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
|
||||
void searchDocuments_senderSort_nullLastNameSortsToEnd() {
|
||||
// Without fix: null lastName produces sort key "null Smith" which compares
|
||||
@@ -1651,7 +1796,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(docNullName, docSmith));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
@@ -1674,7 +1819,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
@@ -1688,8 +1833,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
UNPAGED);
|
||||
null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
@@ -1709,7 +1853,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
|
||||
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
@@ -2226,7 +2370,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(d1, d2));
|
||||
|
||||
List<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());
|
||||
}
|
||||
@@ -2241,7 +2385,7 @@ class DocumentServiceTest {
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
documentService.findIdsForFilter(
|
||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
|
||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false);
|
||||
|
||||
// Spec built without throwing → OR branch was exercised. Coverage gain
|
||||
// is in not-throwing on the OR-specific code path; the actual SQL is
|
||||
@@ -2254,7 +2398,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
List<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();
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||
|
||||
@@ -261,4 +261,21 @@ class DocumentSpecificationsTest {
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── undatedOnly ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void undatedOnly_false_returnsAllDocuments() {
|
||||
// false → no predicate (null), so the filter is a no-op (issue #668).
|
||||
List<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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,9 @@
|
||||
"docs_list_summary": "Zusammenfassung",
|
||||
"docs_list_unknown": "Unbekannt",
|
||||
"docs_group_undated": "Undatiert",
|
||||
"docs_filter_undated_only": "Nur undatierte",
|
||||
"docs_filter_undated_count_label": "{count} undatierte Dokumente",
|
||||
"docs_range_excludes_undated": "Ein Datumsfilter schließt undatierte Dokumente aus, da sie keinem Zeitraum zugeordnet werden können.",
|
||||
"docs_group_unknown": "Unbekannt",
|
||||
"doc_section_who_when": "Wer & Wann",
|
||||
"doc_section_description": "Beschreibung",
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
"docs_list_summary": "Summary",
|
||||
"docs_list_unknown": "Unknown",
|
||||
"docs_group_undated": "Undated",
|
||||
"docs_filter_undated_only": "Undated only",
|
||||
"docs_filter_undated_count_label": "{count} undated documents",
|
||||
"docs_range_excludes_undated": "A date range filter excludes undated documents, because they cannot belong to any time span.",
|
||||
"docs_group_unknown": "Unknown",
|
||||
"doc_section_who_when": "Who & When",
|
||||
"doc_section_description": "Description",
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
"docs_list_summary": "Resumen",
|
||||
"docs_list_unknown": "Desconocido",
|
||||
"docs_group_undated": "Sin fecha",
|
||||
"docs_filter_undated_only": "Solo sin fecha",
|
||||
"docs_filter_undated_count_label": "{count} documentos sin fecha",
|
||||
"docs_range_excludes_undated": "Un filtro de intervalo de fechas excluye los documentos sin fecha, ya que no pueden pertenecer a ningún periodo.",
|
||||
"docs_group_unknown": "Desconocido",
|
||||
"doc_section_who_when": "Quién & Cuándo",
|
||||
"doc_section_description": "Descripción",
|
||||
|
||||
@@ -44,6 +44,17 @@ describe('ChronikRow', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
// --- #668 negative guarantee: Chronik never fabricates a letter date ---
|
||||
it('renders the activity timestamp, not a letter date, and no undated badge', async () => {
|
||||
// The row shows the relative activity time (happenedAt), never the letter's
|
||||
// documentDate — ActivityFeedItemDTO carries no date surface to badge.
|
||||
render(ChronikRow, { item: baseItem });
|
||||
// No undated badge is introduced into a Chronik row.
|
||||
expect(document.querySelector('[data-testid="undated-badge"]')).toBeNull();
|
||||
// No fabricated "Datum unbekannt" letter-date label appears.
|
||||
await expect.element(page.getByText('Datum unbekannt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- simple variant ---
|
||||
it('renders simple variant when count === 1 and not a mention', async () => {
|
||||
render(ChronikRow, { item: baseItem });
|
||||
|
||||
@@ -28,13 +28,21 @@ const showRawLine = $derived(
|
||||
</script>
|
||||
|
||||
<span class="inline-flex flex-col">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if isUnknown}
|
||||
<!-- Non-color cue (WCAG 1.4.1): a calendar-with-question glyph. The visible
|
||||
"Datum unbekannt" text is the redundant textual cue, so the icon is
|
||||
decorative and hidden from assistive tech (per Leonie's a11y note). -->
|
||||
<!--
|
||||
Neutral metadata chip (#668): an undated letter is an absence, NOT an error,
|
||||
so the chip is neutral (text-ink-3 on bg-surface, ≥4.5:1 in both themes) —
|
||||
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
|
||||
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"
|
||||
fill="none"
|
||||
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="M10.5 21h.01" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
{label}
|
||||
</span>
|
||||
{:else}
|
||||
<span>{label}</span>
|
||||
{/if}
|
||||
{#if showRawLine}
|
||||
<!-- Visible secondary line (WCAG 1.4.13 — not tooltip-only). raw is untrusted
|
||||
verbatim spreadsheet text; rendered via default Svelte interpolation, which
|
||||
|
||||
@@ -168,16 +168,12 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
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
|
||||
DocumentListItem payload also intentionally omits metaDateRaw. -->
|
||||
{#if doc.documentDate}
|
||||
<DocumentDate
|
||||
iso={doc.documentDate}
|
||||
precision={doc.metaDatePrecision}
|
||||
end={doc.metaDateEnd}
|
||||
showRaw={false}
|
||||
/>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<ProgressRing percentage={item.completionPercentage} />
|
||||
@@ -191,16 +187,15 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
<!-- 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>
|
||||
{#if doc.documentDate}
|
||||
<!-- An undated document is an absence, not an error (#668): DocumentDate
|
||||
defensively maps a null date to the "Datum unbekannt" badge, so we
|
||||
always render it — never a bare em-dash fallback. -->
|
||||
<DocumentDate
|
||||
iso={doc.documentDate}
|
||||
precision={doc.metaDatePrecision}
|
||||
end={doc.metaDateEnd}
|
||||
showRaw={false}
|
||||
/>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
|
||||
|
||||
@@ -73,6 +73,35 @@ describe('DocumentRow – title', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Date rendering (#668) ──────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – date rendering', () => {
|
||||
it('renders a "Datum unbekannt" badge for an undated document', async () => {
|
||||
const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' });
|
||||
render(DocumentRow, { item });
|
||||
// The badge text appears (once per breakpoint block).
|
||||
await expect.element(page.getByText('Datum unbekannt').first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render a bare em-dash for an undated document', async () => {
|
||||
const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' });
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByText('—', { exact: true }).first()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the full date for a day-precision document', async () => {
|
||||
const item = makeItem({ documentDate: '1943-12-24', metaDatePrecision: 'DAY' });
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByText(/24\. Dezember 1943/).first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders month precision honestly without fabricating a day', async () => {
|
||||
const item = makeItem({ documentDate: '1916-06-01', metaDatePrecision: 'MONTH' });
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByText(/Juni 1916/).first()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snippet ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – snippet', () => {
|
||||
|
||||
@@ -154,10 +154,11 @@ describe('DocumentRow', () => {
|
||||
await expect.element(page.getByTestId('doc-summary')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an em-dash for missing documentDate', async () => {
|
||||
it("renders 'Datum unbekannt' for a missing documentDate", async () => {
|
||||
render(DocumentRow, { props: { item: baseItem({ documentDate: null }) } });
|
||||
|
||||
// Multiple em-dashes possible; just ensure at least one is rendered
|
||||
expect(document.body.textContent).toContain('—');
|
||||
// #668: an undated document renders the "Datum unbekannt" badge (via
|
||||
// <DocumentDate>), never a bare em-dash.
|
||||
await expect.element(page.getByText('Datum unbekannt').first()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2471,6 +2471,8 @@ export interface components {
|
||||
pageSize: number;
|
||||
/** Format: int32 */
|
||||
totalPages: number;
|
||||
/** Format: int64 */
|
||||
undatedCount: number;
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
@@ -5083,6 +5085,8 @@ export interface operations {
|
||||
dir?: string;
|
||||
/** @description Tag operator: AND (default) or OR */
|
||||
tagOp?: string;
|
||||
/** @description Restrict to undated documents (meta_date IS NULL) */
|
||||
undated?: boolean;
|
||||
/** @description Page number (0-indexed) */
|
||||
page?: number;
|
||||
/** @description Page size (max 100) */
|
||||
@@ -5184,6 +5188,7 @@ export interface operations {
|
||||
tagQ?: string;
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
tagOp?: string;
|
||||
undated?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -15,7 +15,9 @@ let {
|
||||
error,
|
||||
total = 0,
|
||||
q = '',
|
||||
sort = 'DATE'
|
||||
sort = 'DATE',
|
||||
from = '',
|
||||
to = ''
|
||||
}: {
|
||||
items: DocumentListItem[];
|
||||
canWrite: boolean;
|
||||
@@ -23,8 +25,15 @@ let {
|
||||
total?: number;
|
||||
q?: string;
|
||||
sort?: SortMode;
|
||||
from?: string;
|
||||
to?: string;
|
||||
} = $props();
|
||||
|
||||
// A from/to range excludes undated documents — when it yields nothing, the
|
||||
// empty state must say so explicitly (a localized constant, never a reflected
|
||||
// backend string). Issue #668.
|
||||
const hasDateRange = $derived(!!from || !!to);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
if (sort === 'SENDER') return groupBySender(items);
|
||||
if (sort === 'RECEIVER') return groupByReceiver(items);
|
||||
@@ -119,7 +128,13 @@ function groupByReceiver(docItems: DocumentListItem[]) {
|
||||
</div>
|
||||
<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">
|
||||
{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>
|
||||
<button
|
||||
onclick={() => goto('/documents')}
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
documentDate: '2024-03-15',
|
||||
metaDatePrecision: 'DAY',
|
||||
sender: undefined,
|
||||
receivers: [],
|
||||
tags: [],
|
||||
@@ -278,3 +279,85 @@ describe('DocumentList – DocumentRow delegation', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ let {
|
||||
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
|
||||
tagQ = $bindable(''),
|
||||
tagOperator = $bindable<'AND' | 'OR'>('AND'),
|
||||
undated = $bindable(false),
|
||||
undatedCount = 0,
|
||||
sort = $bindable('DATE'),
|
||||
dir = $bindable('desc'),
|
||||
showAdvanced = $bindable(false),
|
||||
@@ -35,6 +37,8 @@ let {
|
||||
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
|
||||
tagQ?: string;
|
||||
tagOperator?: 'AND' | 'OR';
|
||||
undated?: boolean;
|
||||
undatedCount?: number;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
showAdvanced?: boolean;
|
||||
@@ -248,6 +252,50 @@ $effect(() => {
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,56 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchFilterBar – undated-only toggle (#668)', () => {
|
||||
async function openAdvanced() {
|
||||
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await filterBtn.click();
|
||||
}
|
||||
|
||||
it('renders the "Nur undatierte" toggle in the advanced row', async () => {
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc' });
|
||||
await openAdvanced();
|
||||
await expect.element(page.getByTestId('undated-only-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects the active undated state via aria-pressed', async () => {
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undated: true });
|
||||
await openAdvanced();
|
||||
await expect
|
||||
.element(page.getByTestId('undated-only-toggle'))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('calls onSearchImmediate when the undated toggle is clicked', async () => {
|
||||
const onSearch = vi.fn();
|
||||
const onSearchImmediate = vi.fn();
|
||||
render(SearchFilterBar, {
|
||||
...defaultProps,
|
||||
onSearch,
|
||||
onSearchImmediate,
|
||||
sort: 'DATE',
|
||||
dir: 'desc'
|
||||
});
|
||||
await openAdvanced();
|
||||
await page.getByTestId('undated-only-toggle').click();
|
||||
await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the global undated count chip when undatedCount > 0', async () => {
|
||||
// The count is the backend's global filtered total (#668), passed straight
|
||||
// through — the chip must render it verbatim, not a page-derived number.
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 42 });
|
||||
await openAdvanced();
|
||||
await expect.element(page.getByTestId('undated-count')).toHaveTextContent('42');
|
||||
});
|
||||
|
||||
it('hides the undated count chip when undatedCount is 0', async () => {
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 0 });
|
||||
await openAdvanced();
|
||||
await expect.element(page.getByTestId('undated-count')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchFilterBar – tagQ live filter', () => {
|
||||
it('calls onSearch when tag text changes in TagInput', async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -46,6 +46,8 @@ export async function load({ url, fetch }) {
|
||||
: 'desc';
|
||||
const tagQ = url.searchParams.get('tagQ') || '';
|
||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
||||
// Narrow the accepted truthy surface to exactly "true" (mirrors the tagOp clamp).
|
||||
const undated = url.searchParams.get('undated') === 'true';
|
||||
const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
@@ -66,6 +68,7 @@ export async function load({ url, fetch }) {
|
||||
tag: tags.length ? tags : undefined,
|
||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||
undated: undated || undefined,
|
||||
sort,
|
||||
dir: dir || undefined,
|
||||
page,
|
||||
@@ -82,6 +85,7 @@ export async function load({ url, fetch }) {
|
||||
pageNumber: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
totalPages: 0,
|
||||
undatedCount: 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
@@ -94,6 +98,7 @@ export async function load({ url, fetch }) {
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp,
|
||||
undated,
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
@@ -112,6 +117,8 @@ export async function load({ url, fetch }) {
|
||||
pageNumber: result.data?.pageNumber ?? page,
|
||||
pageSize: result.data?.pageSize ?? PAGE_SIZE,
|
||||
totalPages: result.data?.totalPages ?? 0,
|
||||
// Global undated count for the active filter, across all pages (issue #668).
|
||||
undatedCount: result.data?.undatedCount ?? 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
@@ -124,6 +131,7 @@ export async function load({ url, fetch }) {
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp,
|
||||
undated,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,10 +32,16 @@ let sort = $state(untrack(() => data.sort || 'DATE'));
|
||||
let dir = $state(untrack(() => data.dir || 'desc'));
|
||||
let tagQ = $state(untrack(() => data.tagQ || ''));
|
||||
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
||||
let undated = $state(untrack(() => data.undated ?? false));
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
(data.tags?.length ?? 0) > 0 || !!data.senderId || !!data.receiverId || !!data.from || !!data.to
|
||||
(data.tags?.length ?? 0) > 0 ||
|
||||
!!data.senderId ||
|
||||
!!data.receiverId ||
|
||||
!!data.from ||
|
||||
!!data.to ||
|
||||
!!data.undated
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +60,7 @@ type FilterSnapshot = {
|
||||
dir: string;
|
||||
tagQ: string;
|
||||
tagOp: 'AND' | 'OR';
|
||||
undated: boolean;
|
||||
zoomFrom?: string | null;
|
||||
zoomTo?: string | null;
|
||||
};
|
||||
@@ -77,6 +84,7 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte
|
||||
if (filters.dir) params.set('dir', filters.dir);
|
||||
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
||||
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||
if (filters.undated) params.set('undated', 'true');
|
||||
if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom);
|
||||
if (filters.zoomTo) params.set('zoomTo', filters.zoomTo);
|
||||
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
|
||||
@@ -112,6 +120,7 @@ function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) {
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp: tagOperator,
|
||||
undated,
|
||||
zoomFrom,
|
||||
zoomTo
|
||||
});
|
||||
@@ -136,7 +145,8 @@ function buildPageHref(targetPage: number): string {
|
||||
sort: data.sort || '',
|
||||
dir: data.dir || '',
|
||||
tagQ: data.tagQ || '',
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
|
||||
undated: data.undated ?? false
|
||||
},
|
||||
targetPage
|
||||
);
|
||||
@@ -188,7 +198,8 @@ async function editAllMatching() {
|
||||
sort: '',
|
||||
dir: '',
|
||||
tagQ: data.tagQ || '',
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
|
||||
undated: data.undated ?? false
|
||||
});
|
||||
params.delete('sort');
|
||||
params.delete('dir');
|
||||
@@ -226,6 +237,7 @@ $effect(() => {
|
||||
dir = data.dir || 'desc';
|
||||
tagQ = data.tagQ || '';
|
||||
tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND';
|
||||
undated = data.undated ?? false;
|
||||
if (hasAdvancedFilters()) showAdvanced = true;
|
||||
});
|
||||
</script>
|
||||
@@ -255,6 +267,8 @@ $effect(() => {
|
||||
bind:dir={dir}
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
undatedCount={data.undatedCount ?? 0}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
navKey={navKey}
|
||||
@@ -343,6 +357,8 @@ $effect(() => {
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
from={data.from}
|
||||
to={data.to}
|
||||
/>
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
|
||||
@@ -100,6 +100,106 @@ describe('documents page load — search params', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards undated=true to the search API as a boolean true', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ undated: 'true' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/documents/search',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({ undated: true })
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits undated from the query when the param is absent', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({}),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
const query = mockGet.mock.calls[0][1].params.query;
|
||||
expect(query.undated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats any undated value other than the literal "true" as not-undated', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ undated: '1' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.undated).toBe(false);
|
||||
expect(mockGet.mock.calls[0][1].params.query.undated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the undated flag in page data when enabled', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ undated: 'true' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.undated).toBe(true);
|
||||
});
|
||||
|
||||
it('does not carry page when toggling undated (page reset)', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
// A bare undated toggle URL carries no page param → loader requests page 0.
|
||||
await load({
|
||||
url: makeUrl({ undated: 'true' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(mockGet.mock.calls[0][1].params.query.page).toBe(0);
|
||||
});
|
||||
|
||||
it('returns items and total from the search result', async () => {
|
||||
const item = {
|
||||
document: { id: 'd1' },
|
||||
@@ -125,6 +225,51 @@ describe('documents page load — search params', () => {
|
||||
expect(result.totalElements).toBe(42);
|
||||
});
|
||||
|
||||
it('forwards the global undatedCount from the search result (#668)', async () => {
|
||||
// The backend returns the global undated total for the active filter across
|
||||
// ALL pages; the loader must pass it straight through, not recompute it locally.
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: {
|
||||
items: [],
|
||||
totalElements: 200,
|
||||
pageNumber: 0,
|
||||
pageSize: 50,
|
||||
totalPages: 4,
|
||||
undatedCount: 73
|
||||
}
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'test' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.undatedCount).toBe(73);
|
||||
});
|
||||
|
||||
it('defaults undatedCount to 0 when the search result omits it', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.undatedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns filter values in the result for pre-filling the UI', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
|
||||
Reference in New Issue
Block a user