Compare commits
8 Commits
929acf6964
...
a345bba74b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a345bba74b | ||
|
|
098c2c9def | ||
|
|
5d8bb70255 | ||
|
|
bca3f34cec | ||
|
|
f1fc3dc1ce | ||
|
|
268c31a49b | ||
|
|
39a462b2bb | ||
|
|
5f2ef823e1 |
@@ -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)
|
||||
|
||||
@@ -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,11 +666,13 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<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)) {
|
||||
// An active undated filter must NOT take this path: it bypasses buildSearchSpec, so the
|
||||
// undatedOnly predicate would be silently dropped.
|
||||
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
||||
return relevanceSortedPageFromSql(text, pageable);
|
||||
}
|
||||
|
||||
@@ -678,7 +683,7 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
Specification<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
|
||||
@@ -797,10 +802,17 @@ public class DocumentService {
|
||||
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
||||
}
|
||||
|
||||
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||
Sort resolveSort(DocumentSort sort, String dir) {
|
||||
Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
if (sort == null || sort == DocumentSort.DATE || sort == DocumentSort.RELEVANCE) {
|
||||
return Sort.by(direction, "documentDate");
|
||||
// Undated documents (null documentDate) must order last regardless of
|
||||
// direction — Postgres puts NULLs FIRST on ASC by default, which would
|
||||
// surface the undated pile at the top with no explanation (issue #668).
|
||||
// The createdAt tiebreaker gives a stable total order when every row is
|
||||
// null-dated (the "Nur undatierte" filter), so pagination is deterministic.
|
||||
return Sort.by(
|
||||
new Sort.Order(direction, "documentDate").nullsLast(),
|
||||
Sort.Order.asc("createdAt"));
|
||||
}
|
||||
// SENDER and RECEIVER are sorted in-memory before this method is called
|
||||
return switch (sort) {
|
||||
|
||||
@@ -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);
|
||||
@@ -116,12 +112,10 @@ class DocumentSearchPagedIntegrationTest {
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(1, 50));
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50));
|
||||
|
||||
// No document id should appear on both pages — slicing must be exclusive.
|
||||
var idsOnPage0 = page0.items().stream()
|
||||
|
||||
@@ -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,40 @@ 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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() {
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
@@ -1457,8 +1490,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
DocumentSort.UPDATED_AT, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getSort())
|
||||
@@ -1482,8 +1514,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
@@ -1507,8 +1538,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(30L);
|
||||
@@ -1521,7 +1551,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, false, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
@@ -1531,7 +1561,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
@@ -1609,7 +1639,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(withSender, noSender));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
||||
@@ -1629,12 +1659,53 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(noReceivers, withReceiver));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — undated docs stay in their person group (#668) ───────
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_keepsUndatedDocumentUnderItsSender() {
|
||||
// Locking test: the in-memory SENDER comparator orders by sender name, not
|
||||
// date, so an undated (null documentDate) letter must NOT be pulled out of
|
||||
// its sender's group — it sorts by sender exactly like a dated letter.
|
||||
Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build();
|
||||
Document datedFromAlice = Document.builder().id(UUID.randomUUID()).title("Dated")
|
||||
.sender(alice).documentDate(LocalDate.of(1916, 6, 15)).build();
|
||||
Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated")
|
||||
.sender(alice).documentDate(null).build();
|
||||
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(datedFromAlice, undatedFromAlice));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
||||
|
||||
// Both stay together under Alice; neither is dropped or reordered by date.
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
.containsExactlyInAnyOrder("Dated", "Undated");
|
||||
assertThat(result.items()).allMatch(item -> item.sender().getId().equals(alice.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut() {
|
||||
// undated=true must bypass the pure-text RELEVANCE SQL shortcut, which
|
||||
// skips buildSearchSpec and would silently drop the undatedOnly predicate.
|
||||
when(documentRepository.findAllMatchingIdsByFts("brief")).thenReturn(List.of(UUID.randomUUID()));
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.searchDocuments("brief", null, null, null, null, null, null, null,
|
||||
DocumentSort.RELEVANCE, null, null, true, UNPAGED);
|
||||
|
||||
// The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not.
|
||||
verify(documentRepository).findAllMatchingIdsByFts("brief");
|
||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_nullLastNameSortsToEnd() {
|
||||
// Without fix: null lastName produces sort key "null Smith" which compares
|
||||
@@ -1651,7 +1722,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(docNullName, docSmith));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
@@ -1674,7 +1745,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
@@ -1688,8 +1759,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
UNPAGED);
|
||||
null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
@@ -1709,7 +1779,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
|
||||
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
@@ -2226,7 +2296,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 +2311,7 @@ class DocumentServiceTest {
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
documentService.findIdsForFilter(
|
||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
|
||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false);
|
||||
|
||||
// Spec built without throwing → OR branch was exercised. Coverage gain
|
||||
// is in not-throwing on the OR-specific code path; the actual SQL is
|
||||
@@ -2254,7 +2324,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,104 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.raddatz.familienarchiv.document.DocumentSpecifications.isBetween;
|
||||
import static org.raddatz.familienarchiv.document.DocumentSpecifications.undatedOnly;
|
||||
|
||||
/**
|
||||
* Real-Postgres assertions for issue #668. H2 disagrees with Postgres on
|
||||
* {@code NULLS FIRST/LAST} defaults and on whether {@code BETWEEN} excludes
|
||||
* NULL, so these guarantees MUST run against {@code postgres:16-alpine}, never
|
||||
* an in-memory database.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class UndatedDocumentOrderingIntegrationTest {
|
||||
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
documentRepository.deleteAll();
|
||||
save("1916", LocalDate.of(1916, 6, 15));
|
||||
save("1943", LocalDate.of(1943, 12, 24));
|
||||
save("undated-a", null);
|
||||
save("undated-b", null);
|
||||
}
|
||||
|
||||
private void save(String title, LocalDate date) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title(title)
|
||||
.originalFilename(title + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(date == null ? DatePrecision.UNKNOWN : DatePrecision.DAY)
|
||||
.documentDate(date)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dateAscWithNullsLast_returnsDatedFirstUndatedLast() {
|
||||
Sort sort = Sort.by(new Sort.Order(Sort.Direction.ASC, "documentDate").nullsLast());
|
||||
|
||||
List<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 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,8 @@
|
||||
"docs_list_summary": "Zusammenfassung",
|
||||
"docs_list_unknown": "Unbekannt",
|
||||
"docs_group_undated": "Undatiert",
|
||||
"docs_filter_undated_only": "Nur undatierte",
|
||||
"docs_range_excludes_undated": "Ein Datumsfilter schließt undatierte Dokumente aus, da sie keinem Zeitraum zugeordnet werden können.",
|
||||
"docs_group_unknown": "Unbekannt",
|
||||
"doc_section_who_when": "Wer & Wann",
|
||||
"doc_section_description": "Beschreibung",
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
"docs_list_summary": "Summary",
|
||||
"docs_list_unknown": "Unknown",
|
||||
"docs_group_undated": "Undated",
|
||||
"docs_filter_undated_only": "Undated only",
|
||||
"docs_range_excludes_undated": "A date range filter excludes undated documents, because they cannot belong to any time span.",
|
||||
"docs_group_unknown": "Unknown",
|
||||
"doc_section_who_when": "Who & When",
|
||||
"doc_section_description": "Description",
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
"docs_list_summary": "Resumen",
|
||||
"docs_list_unknown": "Desconocido",
|
||||
"docs_group_undated": "Sin fecha",
|
||||
"docs_filter_undated_only": "Solo sin fecha",
|
||||
"docs_range_excludes_undated": "Un filtro de intervalo de fechas excluye los documentos sin fecha, ya que no pueden pertenecer a ningún periodo.",
|
||||
"docs_group_unknown": "Desconocido",
|
||||
"doc_section_who_when": "Quién & Cuándo",
|
||||
"doc_section_description": "Descripción",
|
||||
|
||||
@@ -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). -->
|
||||
{#if isUnknown}
|
||||
<!--
|
||||
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-[10px] 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,13 @@ 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>
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span>{label}</span>
|
||||
</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}
|
||||
<DocumentDate
|
||||
iso={doc.documentDate}
|
||||
precision={doc.metaDatePrecision}
|
||||
end={doc.metaDateEnd}
|
||||
showRaw={false}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<ProgressRing percentage={item.completionPercentage} />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -5083,6 +5083,8 @@ export interface operations {
|
||||
dir?: string;
|
||||
/** @description Tag operator: AND (default) or OR */
|
||||
tagOp?: string;
|
||||
/** @description Restrict to undated documents (meta_date IS NULL) */
|
||||
undated?: boolean;
|
||||
/** @description Page number (0-indexed) */
|
||||
page?: number;
|
||||
/** @description Page size (max 100) */
|
||||
@@ -5184,6 +5186,7 @@ export interface operations {
|
||||
tagQ?: string;
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
tagOp?: string;
|
||||
undated?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -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,7 @@ let {
|
||||
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
|
||||
tagQ = $bindable(''),
|
||||
tagOperator = $bindable<'AND' | 'OR'>('AND'),
|
||||
undated = $bindable(false),
|
||||
sort = $bindable('DATE'),
|
||||
dir = $bindable('desc'),
|
||||
showAdvanced = $bindable(false),
|
||||
@@ -35,6 +36,7 @@ let {
|
||||
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
|
||||
tagQ?: string;
|
||||
tagOperator?: 'AND' | 'OR';
|
||||
undated?: boolean;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
showAdvanced?: boolean;
|
||||
@@ -248,6 +250,33 @@ $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()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,42 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchFilterBar – undated-only toggle (#668)', () => {
|
||||
async function openAdvanced() {
|
||||
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await filterBtn.click();
|
||||
}
|
||||
|
||||
it('renders the "Nur undatierte" toggle in the advanced row', async () => {
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc' });
|
||||
await openAdvanced();
|
||||
await expect.element(page.getByTestId('undated-only-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects the active undated state via aria-pressed', async () => {
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undated: true });
|
||||
await openAdvanced();
|
||||
await expect
|
||||
.element(page.getByTestId('undated-only-toggle'))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('calls onSearchImmediate when the undated toggle is clicked', async () => {
|
||||
const onSearch = vi.fn();
|
||||
const onSearchImmediate = vi.fn();
|
||||
render(SearchFilterBar, {
|
||||
...defaultProps,
|
||||
onSearch,
|
||||
onSearchImmediate,
|
||||
sort: 'DATE',
|
||||
dir: 'desc'
|
||||
});
|
||||
await openAdvanced();
|
||||
await page.getByTestId('undated-only-toggle').click();
|
||||
await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchFilterBar – tagQ live filter', () => {
|
||||
it('calls onSearch when tag text changes in TagInput', async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -46,6 +46,8 @@ export async function load({ url, fetch }) {
|
||||
: 'desc';
|
||||
const tagQ = url.searchParams.get('tagQ') || '';
|
||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
||||
// Narrow the accepted truthy surface to exactly "true" (mirrors the tagOp clamp).
|
||||
const undated = url.searchParams.get('undated') === 'true';
|
||||
const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
@@ -66,6 +68,7 @@ export async function load({ url, fetch }) {
|
||||
tag: tags.length ? tags : undefined,
|
||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||
undated: undated || undefined,
|
||||
sort,
|
||||
dir: dir || undefined,
|
||||
page,
|
||||
@@ -94,6 +97,7 @@ export async function load({ url, fetch }) {
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp,
|
||||
undated,
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
@@ -124,6 +128,7 @@ export async function load({ url, fetch }) {
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp,
|
||||
undated,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,7 @@ $effect(() => {
|
||||
bind:dir={dir}
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
navKey={navKey}
|
||||
@@ -343,6 +356,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' },
|
||||
|
||||
Reference in New Issue
Block a user