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
8 changed files with 167 additions and 151 deletions
Showing only changes of commit dcb57ffacd - Show all commits

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,10 @@ 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);
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 +499,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 +517,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 +657,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 +668,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 +682,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 +741,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

@@ -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,15 @@ 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
@@ -1237,7 +1240,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

@@ -122,8 +122,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +137,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)))
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.SENDER, "asc", PageRequest.of(0, 20)))
.doesNotThrowAnyException();
}

View File

@@ -55,8 +55,8 @@ class DocumentListItemIntegrationTest {
.build());
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)))
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50)))
.doesNotThrowAnyException();
}
@@ -70,8 +70,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(result.totalElements()).isGreaterThan(0);
DocumentListItem item = result.items().get(0);
@@ -91,8 +91,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
DocumentListItem item = result.items().stream()
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();

View File

@@ -61,8 +61,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -74,8 +74,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +86,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PageRequest.of(99, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -99,8 +99,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.SENDER, "asc", PageRequest.of(1, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -125,8 +125,8 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +153,11 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult unfiltered = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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));
new SearchFilters(null, null, null, null, null, null, null, null, null, true),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
@@ -178,9 +178,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 +188,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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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));
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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

@@ -1441,8 +1441,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1455,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1472,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1489,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1513,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1535,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1560,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1585,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1600,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 +1611,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(
new SearchFilters(null, null, null, null, null, null, null, null, null, false), null, null, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
}
@@ -1680,7 +1690,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);
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.SENDER, "asc", UNPAGED);
assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
@@ -1700,7 +1711,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);
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.RECEIVER, "asc", UNPAGED);
assertThat(result.items()).extracting(DocumentListItem::title)
.containsExactly("Has Receiver", "No Receivers");
@@ -1733,7 +1745,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);
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1777,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);
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1801,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);
new SearchFilters(null, null, null, null, null, null, null, null, null, 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 +1818,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 +1843,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);
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
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 +1867,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 +1882,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);
new SearchFilters(null, null, null, null, null, null, null, null, null, false),
null, null, UNPAGED);
assertThat(result.items()).isEmpty();
}
@@ -1884,7 +1903,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 +2421,7 @@ class DocumentServiceTest {
.thenReturn(List.of(d1, d2));
List<UUID> result = documentService.findIdsForFilter(
null, null, null, null, null, null, null, null, null, false);
new SearchFilters(null, null, null, null, null, null, null, null, null, false));
assertThat(result).containsExactly(d1.getId(), d2.getId());
}
@@ -2416,7 +2436,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 +2449,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));