refactor(document): extract a SearchFilters record for the document search signatures (#683) #702

Merged
marcel merged 5 commits from feat/issue-683-search-filters-record into main 2026-05-31 16:17:46 +02:00
10 changed files with 249 additions and 151 deletions

View File

@@ -316,7 +316,8 @@ public class DocumentController {
@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, Boolean.TRUE.equals(undated));
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
List<UUID> ids = documentService.findIdsForFilter(filters);
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 + ")");
@@ -388,8 +389,9 @@ public class DocumentController {
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
// defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
Pageable pageable = PageRequest.of(page, size);
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, Boolean.TRUE.equals(undated), pageable));
return ResponseEntity.ok(documentService.searchDocuments(filters, sort, dir, pageable));
}
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)

View File

@@ -167,11 +167,13 @@ public class DocumentService {
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
boolean hasFts = ftsIds != null;
Specification<Document> spec = buildSearchSpec(
hasFts, ftsIds, null, null,
filters.sender(), filters.receiver(),
filters.tags(), filters.tagQ(),
filters.status(), filters.tagOperator(), false);
// Density and search keep separate filter records (DensityFilters has no
// date/undated fields); adapt to SearchFilters here to reuse buildSearchSpec.
// Date bounds stay null and undated=false — the density path never filters by date.
SearchFilters searchFilters = new SearchFilters(
filters.text(), null, null, filters.sender(), filters.receiver(),
filters.tags(), filters.tagQ(), filters.status(), filters.tagOperator(), false);
Specification<Document> spec = buildSearchSpec(hasFts, ftsIds, searchFilters);
return documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate)
.filter(Objects::nonNull)
@@ -500,18 +502,15 @@ public class DocumentService {
* round-trip.
*/
@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,
boolean undated) {
boolean hasText = StringUtils.hasText(text);
public List<UUID> findIdsForFilter(SearchFilters filters) {
boolean hasText = StringUtils.hasText(filters.text());
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
if (rankedIds.isEmpty()) return List.of();
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
}
@@ -521,23 +520,18 @@ public class DocumentService {
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
* full-text query returned no rows.
*/
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to,
UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator,
boolean undated) {
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
boolean useOrLogic = filters.tagOperator() == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(filters.tags());
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
return Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(isBetween(filters.from(), filters.to()))
.and(hasSender(filters.sender()))
.and(hasReceiver(filters.receiver()))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status))
.and(undatedOnly(undated));
.and(hasTagPartial(filters.tagQ()))
.and(hasStatus(filters.status()))
.and(undatedOnly(filters.undated()));
}
/**
@@ -666,8 +660,8 @@ 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, boolean undated, Pageable pageable) {
boolean hasText = StringUtils.hasText(text);
public DocumentSearchResult searchDocuments(SearchFilters filters, DocumentSort sort, String dir, Pageable pageable) {
boolean hasText = StringUtils.hasText(filters.text());
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
@@ -677,13 +671,13 @@ public class DocumentService {
// no date/sender/receiver/tag/status filters, and undated documents are valid
// FTS hits already folded into the ranked page, so there is no separate undated
// count to report here.
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
return relevanceSortedPageFromSql(text, pageable);
if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
return relevanceSortedPageFromSql(filters.text(), pageable);
}
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
// FTS matched nothing → no results and, by definition, no undated matches either.
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
@@ -691,37 +685,32 @@ public class DocumentService {
// Global undated count for the current filter (q/tags/sender/receiver/status),
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
// it never collapses to the page slice and never double-counts (issue #668).
long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
long undatedCount = countUndatedForFilter(hasText, rankedIds, filters.withUndated(true));
return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable)
return runSearch(hasText, rankedIds, filters, sort, dir, pageable)
.withUndatedCount(undatedCount);
}
/**
* Counts every undated document (meta_date IS NULL) matching the active filter,
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
* so the count is legitimately 0 inside a date range.
* across all pages, independent of the undated toggle. The caller passes
* {@code filters.withUndated(true)} so the count tracks q/tags/sender/receiver/status
* regardless of the user's "Nur undatierte" toggle. A {@code from}/{@code to} range
* excludes undated rows by the collision rule (#668), so the count is legitimately 0
* inside a date range.
*/
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
Specification<Document> undatedSpec = buildSearchSpec(
hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true);
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
Specification<Document> undatedSpec = buildSearchSpec(hasText, ftsIds, filters);
return documentRepository.count(undatedSpec);
}
/** The original search dispatch — produces the page slice + totals, sans undated count. */
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status,
DocumentSort sort, String dir, TagOperator tagOperator,
boolean undated, Pageable pageable) {
private DocumentSearchResult runSearch(boolean hasText, List<UUID> rankedIds, SearchFilters filters,
DocumentSort sort, String dir, Pageable pageable) {
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
String text = filters.text();
// 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
@@ -755,12 +744,12 @@ public class DocumentService {
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
}
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status) {
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, SearchFilters filters) {
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
&& from == null && to == null && sender == null && receiver == null
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
&& filters.from() == null && filters.to() == null
&& filters.sender() == null && filters.receiver() == null
&& (filters.tags() == null || filters.tags().isEmpty())
&& (filters.tagQ() == null || filters.tagQ().isBlank()) && filters.status() == null;
}
/**

View File

@@ -0,0 +1,40 @@
package org.raddatz.familienarchiv.document;
import org.raddatz.familienarchiv.tag.TagOperator;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* The filter predicates honoured by {@link DocumentService#searchDocuments} and
* {@link DocumentService#findIdsForFilter}. Sort, direction, and pagination are
* deliberately excluded — they are not filter predicates, and {@code findIdsForFilter}
* needs none of them; they are passed as separate arguments instead.
*
* Kept as a record so the ten values are passed as one named bundle instead of a
* positional argument list where two UUIDs (sender vs. receiver) or two dates
* (from vs. to) can be swapped by accident at the call site — a transposition that
* compiles cleanly and silently returns the wrong rows.
*
* Sibling of {@link DensityFilters} (= these fields minus from/to/undated); kept
* separate on purpose, so the density call path never reasons about date/undated
* fields it deliberately excludes.
*/
public record SearchFilters(
String text,
LocalDate from,
LocalDate to,
UUID sender,
UUID receiver,
List<String> tags,
String tagQ,
DocumentStatus status,
TagOperator tagOperator,
boolean undated) {
/** Returns a copy with {@code undated} overridden — used by the undated-count path. */
public SearchFilters withUndated(boolean undated) {
return new SearchFilters(text, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
}
}

View File

@@ -38,7 +38,6 @@ 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;
@@ -76,7 +75,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -88,7 +87,7 @@ class DocumentControllerTest {
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()))
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
@@ -104,41 +103,43 @@ class DocumentControllerTest {
@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()))
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.searchDocuments(any(), any(), any(), 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();
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(filtersCaptor.getValue().undated()).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()))
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.searchDocuments(any(), any(), any(), 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();
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(filtersCaptor.getValue().undated()).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(), anyBoolean(), any()))
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.searchDocuments(any(), any(), any(), 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(), anyBoolean(), any());
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(filtersCaptor.getValue().status()).isEqualTo(DocumentStatus.REVIEWED);
}
@Test
@@ -165,7 +166,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -180,7 +181,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(), anyBoolean(), any()))
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, null, null,
@@ -200,7 +201,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(), anyBoolean(), any()))
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, null, null,
@@ -223,7 +224,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_responseExposesPagingFields() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -268,7 +269,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_passesPageRequestToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
@@ -276,7 +277,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(), anyBoolean(), captor.capture());
verify(documentService).searchDocuments(any(), any(), any(), 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);
@@ -1208,7 +1209,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(), anyBoolean()))
when(documentService.findIdsForFilter(any()))
.thenReturn(List.of(id));
mockMvc.perform(get("/api/documents/ids"))
@@ -1221,13 +1222,33 @@ 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(), anyBoolean()))
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.findIdsForFilter(any()))
.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(), anyBoolean());
verify(documentService).findIdsForFilter(filtersCaptor.capture());
assertThat(filtersCaptor.getValue().sender()).isEqualTo(senderId);
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_withoutUndatedParam_coercesNullToFalse() throws Exception {
// The controller coerces a null boxed Boolean to primitive false
// (Boolean.TRUE.equals(undated)) so the absent param never NPEs and the
// record always holds a concrete boolean.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.findIdsForFilter(any()))
.thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isOk());
verify(documentService).findIdsForFilter(filtersCaptor.capture());
assertThat(filtersCaptor.getValue().undated()).isFalse();
}
@Test
@@ -1237,7 +1258,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(), anyBoolean()))
when(documentService.findIdsForFilter(any()))
.thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids"))

View File

@@ -24,6 +24,7 @@ import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@@ -122,8 +123,8 @@ class DocumentLazyLoadingTest {
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20));
noFilters(),
DocumentSort.RECEIVER, "asc", PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() ->
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
@@ -137,8 +138,8 @@ class DocumentLazyLoadingTest {
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20)))
noFilters(),
DocumentSort.SENDER, "asc", PageRequest.of(0, 20)))
.doesNotThrowAnyException();
}

View File

@@ -17,6 +17,7 @@ import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
@@ -55,8 +56,8 @@ class DocumentListItemIntegrationTest {
.build());
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)))
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50)))
.doesNotThrowAnyException();
}
@@ -70,8 +71,8 @@ class DocumentListItemIntegrationTest {
.build());
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(result.totalElements()).isGreaterThan(0);
DocumentListItem item = result.items().get(0);
@@ -91,8 +92,8 @@ class DocumentListItemIntegrationTest {
.build());
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
DocumentListItem item = result.items().stream()
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();

View File

@@ -21,6 +21,7 @@ import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
/**
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
@@ -61,8 +62,8 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -74,8 +75,8 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_lastPartialPage_returnsRemainingItems() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(2, 50));
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
assertThat(result.items()).hasSize(20);
@@ -86,8 +87,8 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(99, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -99,8 +100,8 @@ class DocumentSearchPagedIntegrationTest {
// comment in DocumentService). Proves that the in-memory slice path
// 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, false, PageRequest.of(1, 50));
noFilters(),
DocumentSort.SENDER, "asc", PageRequest.of(1, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -125,8 +126,8 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
// Global undated count is the full undated total, independent of page size.
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
@@ -153,11 +154,11 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult unfiltered = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
DocumentSearchResult undatedOnly = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
noFilters().withUndated(true),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
@@ -178,9 +179,9 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult result = documentService.searchDocuments(
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
new SearchFilters(null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(result.undatedCount()).isZero();
}
@@ -188,11 +189,11 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_differentPagesReturnDisjointSlices() {
DocumentSearchResult page0 = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
DocumentSearchResult page1 = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50));
noFilters(),
DocumentSort.DATE, "DESC", 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,8 @@ 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, false, PAGE);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PAGE);
assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
@@ -84,7 +85,8 @@ class DocumentServiceSortTest {
.thenReturn(List.of(doc(id1)));
documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
@@ -102,7 +104,8 @@ 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, false, PAGE);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
}
@@ -119,7 +122,8 @@ 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, false, PAGE);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
null, null, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
}
@@ -132,8 +136,8 @@ class DocumentServiceSortTest {
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, false, hugePage);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, hugePage);
assertThat(result.items()).isEmpty();
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
@@ -152,8 +156,8 @@ class DocumentServiceSortTest {
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, false, PAGE);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
@@ -173,7 +177,8 @@ 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, false, PAGE);
new SearchFilters("Brief", from, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository).findAllMatchingIdsByFts("Brief");

View File

@@ -45,6 +45,7 @@ import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -1441,8 +1442,9 @@ 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,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", 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));
@@ -1454,8 +1456,9 @@ 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,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25));
documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", 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);
@@ -1470,8 +1473,9 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.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, false, org.springframework.data.domain.PageRequest.of(0, 50));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isZero();
@@ -1486,8 +1490,9 @@ 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,
DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", 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");
@@ -1509,8 +1514,9 @@ 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,
DocumentSort.DATE, "ASC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "ASC", 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");
@@ -1530,8 +1536,9 @@ 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,
DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
documentService.searchDocuments(
noFilters(),
DocumentSort.UPDATED_AT, "DESC", 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())
@@ -1554,8 +1561,9 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(1, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1);
@@ -1578,8 +1586,9 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(10, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(30L);
@@ -1592,7 +1601,8 @@ 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, false, UNPAGED);
documentService.searchDocuments(
new SearchFilters(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, false), null, null, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
}
@@ -1602,7 +1612,8 @@ 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, false, UNPAGED);
documentService.searchDocuments(
noFilters(), null, null, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
}
@@ -1680,7 +1691,8 @@ class DocumentServiceTest {
.thenReturn(List.of(withSender, noSender));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
noFilters(),
DocumentSort.SENDER, "asc", UNPAGED);
assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
@@ -1700,7 +1712,8 @@ class DocumentServiceTest {
.thenReturn(List.of(noReceivers, withReceiver));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, false, UNPAGED);
noFilters(),
DocumentSort.RECEIVER, "asc", UNPAGED);
assertThat(result.items()).extracting(DocumentListItem::title)
.containsExactly("Has Receiver", "No Receivers");
@@ -1733,7 +1746,8 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
noFilters(),
DocumentSort.SENDER, "asc", UNPAGED);
// Bob's group precedes Anna's group (ASC by sender). The sort is stable, so
// within each group the input order is preserved (undatedBob, datedBob for Bob;
@@ -1764,7 +1778,8 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "desc", null, false, UNPAGED);
noFilters(),
DocumentSort.SENDER, "desc", UNPAGED);
// Anna's group precedes Bob's (DESC by sender); undated stays inside its group.
assertThat(result.items()).extracting(DocumentListItem::title)
@@ -1787,7 +1802,8 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedFromAlice));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, true, UNPAGED);
noFilters().withUndated(true),
DocumentSort.SENDER, "asc", UNPAGED);
// The in-memory path queried via a Specification (built by buildSearchSpec with
// undatedOnly(true)) rather than skipping straight to a sorted findAll.
@@ -1803,8 +1819,9 @@ class DocumentServiceTest {
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);
documentService.searchDocuments(
new SearchFilters("brief", null, null, null, null, null, null, null, null, true),
DocumentSort.RELEVANCE, null, UNPAGED);
// The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not.
verify(documentRepository).findAllMatchingIdsByFts("brief");
@@ -1827,7 +1844,8 @@ class DocumentServiceTest {
.thenReturn(List.of(docNullName, docSmith));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
noFilters(),
DocumentSort.SENDER, "asc", UNPAGED);
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(DocumentListItem::title)
@@ -1850,7 +1868,8 @@ 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, false, UNPAGED);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, UNPAGED);
assertThat(result.items()).hasSize(1);
SearchMatchData md = result.items().get(0).matchData();
@@ -1864,7 +1883,8 @@ class DocumentServiceTest {
.thenReturn(new PageImpl<>(List.of()));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
noFilters(),
null, null, UNPAGED);
assertThat(result.items()).isEmpty();
}
@@ -1884,7 +1904,8 @@ 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, false, UNPAGED);
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, UNPAGED);
SearchMatchData md = result.items().get(0).matchData();
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
@@ -2401,7 +2422,7 @@ class DocumentServiceTest {
.thenReturn(List.of(d1, d2));
List<UUID> result = documentService.findIdsForFilter(
null, null, null, null, null, null, null, null, null, false);
noFilters());
assertThat(result).containsExactly(d1.getId(), d2.getId());
}
@@ -2416,7 +2437,7 @@ class DocumentServiceTest {
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
documentService.findIdsForFilter(
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false);
new SearchFilters(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
@@ -2429,7 +2450,7 @@ class DocumentServiceTest {
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter(
"xyz", null, null, null, null, null, null, null, null, false);
new SearchFilters("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

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.document;
/** Test fixtures for {@link SearchFilters}. */
final class SearchFiltersFixtures {
private SearchFiltersFixtures() {}
/**
* A {@link SearchFilters} with no predicate active — the common search-test
* baseline. Combine with {@code .withUndated(true)} for the undated-only case;
* construct {@code new SearchFilters(...)} directly when a test pins a specific
* field, so the intent stays visible at the call site.
*/
static SearchFilters noFilters() {
return new SearchFilters(null, null, null, null, null, null, null, null, null, false);
}
}