Compare commits

...

8 Commits

Author SHA1 Message Date
Marcel
a345bba74b test(activity): assert Chronik rows never fabricate a letter date
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m30s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Negative guarantee for #668: ChronikRow renders the activity timestamp
(happenedAt), and ActivityFeedItemDTO carries no document-date surface, so
no undated badge or "Datum unbekannt" letter-date label may appear. Pins
this as a regression fixture so a future change can't quietly add a date
chip to the activity feed.

Refs #668
2026-05-27 18:54:35 +02:00
Marcel
098c2c9def feat(documents): add a "Nur undatierte" filter toggle wired to the URL
SearchFilterBar gains an aria-pressed "Nur undatierte" toggle in the
advanced row (min-h-[44px] touch target, labels the state not the colour).
The documents page threads `undated` through the filter snapshot so it is a
shareable URL param picked up by both filter-change nav and pagination, and
flows into the bulk-edit "select all" /ids request. Toggling resets to page
0 via the existing implicit page-drop.

Refs #668
2026-05-27 18:53:44 +02:00
Marcel
5d8bb70255 feat(documents): explain that a date range excludes undated documents
DocumentList gains from/to props; when a date range is active and yields no
results, the empty state shows the localized docs_range_excludes_undated
note instead of the generic copy, so the reader understands undated letters
aren't part of a range. Person-grouped modes keep undated letters under
their sender/receiver (badge-on-row, no synthetic sub-group).

Refs #668
2026-05-27 18:50:18 +02:00
Marcel
bca3f34cec feat(documents): badge undated rows instead of a bare em-dash
DocumentRow rendered a bare em-dash for null-dated letters — a glyph a
screen reader announces as nothing. Both breakpoints now render the single
DocumentDate component unconditionally (no {#if}/—/{:else}), so the cue
cannot drift; its unknown state is a neutral metadata chip ("Datum
unbekannt", text-ink-3, ≥4.5:1 both themes) with a non-color calendar glyph,
never red/amber. Present dates render at honest precision via
formatDocumentDate ("Juni 1916", not a fabricated day).

Refs #668
2026-05-27 18:48:45 +02:00
Marcel
f1fc3dc1ce feat(documents): thread undated filter through the search loader + i18n
Parses ?undated strictly (=== 'true', mirroring the tagOp clamp), forwards
it as undated || undefined so the absent case drops out of the query, and
returns the flag in page data for the control to reflect. Adds the
docs_filter_undated_only toggle label and the explanatory
docs_range_excludes_undated empty-state copy in de/en/es. The badge reuses
the existing date_precision_unknown ("Datum unbekannt") key from #677.

OpenAPI types hand-edited for the new undated query param on /search and
/ids — CI must run `npm run generate:api` to confirm parity with the spec.

Refs #668
2026-05-27 18:45:03 +02:00
Marcel
268c31a49b feat(document): thread an undated filter through search and the /ids path
Adds an optional `undated` query param to GET /api/documents/search and
/api/documents/ids, threaded through searchDocuments and findIdsForFilter
into the shared buildSearchSpec via undatedOnly(boolean). undated=true also
bypasses the pure-text RELEVANCE SQL shortcut, which skips buildSearchSpec
and would otherwise drop the predicate. The read GET stays unguarded
(WebMvc authz test pins 200 for an authenticated user, 401 unauthenticated).
A locking test proves the in-memory SENDER sort keeps undated letters under
their sender.

Refs #668
2026-05-27 18:42:17 +02:00
Marcel
39a462b2bb feat(document): add undatedOnly Specification for the undated-only filter
undatedOnly(false) is a no-op (null predicate); undatedOnly(true) returns
documentDate IS NULL, matching the existing hasStatus null-as-no-op pattern.
Real-Postgres tests pin the load-bearing guarantees H2 cannot prove: ASC
NULLS-LAST ordering, BETWEEN excludes null-dated rows, and that undated=true
combined with a from/to range returns empty (the collision rule).

Refs #668
2026-05-27 18:34:10 +02:00
Marcel
5f2ef823e1 fix(document): order undated documents last on the DATE sort fast path
resolveSort produced Sort.by(direction, "documentDate") with NATIVE null
handling, so Postgres surfaced undated (null meta_date) documents FIRST on
an ASC sort. Apply nullsLast() so undated rows order last for both ASC and
DESC, with a createdAt-asc tiebreaker for a stable total order when every
row is null-dated (the upcoming "Nur undatierte" filter).

Refs #668
2026-05-27 18:31:40 +02:00
26 changed files with 691 additions and 102 deletions

View File

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

View File

@@ -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) {

View File

@@ -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.
*

View File

@@ -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"))

View File

@@ -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();
}

View File

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

View File

@@ -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()

View File

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

View File

@@ -47,6 +47,8 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.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));

View File

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

View File

@@ -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();
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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

View File

@@ -168,16 +168,12 @@ function safeTagColor(color: string | null | undefined): string {
document DETAIL page, never in list/search rows — list rows surface only the
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} />

View File

@@ -73,6 +73,35 @@ describe('DocumentRow title', () => {
});
});
// ─── Date rendering (#668) ──────────────────────────────────────────────────
describe('DocumentRow date rendering', () => {
it('renders a "Datum unbekannt" badge for an undated document', async () => {
const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' });
render(DocumentRow, { item });
// The badge text appears (once per breakpoint block).
await expect.element(page.getByText('Datum unbekannt').first()).toBeInTheDocument();
});
it('does not render a bare em-dash for an undated document', async () => {
const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' });
render(DocumentRow, { item });
await expect.element(page.getByText('—', { exact: true }).first()).not.toBeInTheDocument();
});
it('renders the full date for a day-precision document', async () => {
const item = makeItem({ documentDate: '1943-12-24', metaDatePrecision: 'DAY' });
render(DocumentRow, { item });
await expect.element(page.getByText(/24\. Dezember 1943/).first()).toBeInTheDocument();
});
it('renders month precision honestly without fabricating a day', async () => {
const item = makeItem({ documentDate: '1916-06-01', metaDatePrecision: 'MONTH' });
render(DocumentRow, { item });
await expect.element(page.getByText(/Juni 1916/).first()).toBeInTheDocument();
});
});
// ─── Snippet ──────────────────────────────────────────────────────────────────
describe('DocumentRow snippet', () => {

View File

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

View File

@@ -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')}

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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(

View File

@@ -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
};
}

View File

@@ -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} />

View File

@@ -100,6 +100,106 @@ describe('documents page load — search params', () => {
);
});
it('forwards undated=true to the search API as a boolean true', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({
url: makeUrl({ undated: 'true' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(mockGet).toHaveBeenCalledWith(
'/api/documents/search',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ undated: true })
})
})
);
});
it('omits undated from the query when the param is absent', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({
url: makeUrl({}),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
const query = mockGet.mock.calls[0][1].params.query;
expect(query.undated).toBeUndefined();
});
it('treats any undated value other than the literal "true" as not-undated', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({
url: makeUrl({ undated: '1' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.undated).toBe(false);
expect(mockGet.mock.calls[0][1].params.query.undated).toBeUndefined();
});
it('returns the undated flag in page data when enabled', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({
url: makeUrl({ undated: 'true' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.undated).toBe(true);
});
it('does not carry page when toggling undated (page reset)', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
// A bare undated toggle URL carries no page param → loader requests page 0.
await load({
url: makeUrl({ undated: 'true' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(mockGet.mock.calls[0][1].params.query.page).toBe(0);
});
it('returns items and total from the search result', async () => {
const item = {
document: { id: 'd1' },