refactor(document): thread SearchFilters through the search chain (#683)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m26s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s

Replace the long positional filter lists on the document search chain
with the SearchFilters record. searchDocuments now takes
(SearchFilters, DocumentSort, String dir, Pageable) and findIdsForFilter
takes a single SearchFilters; the four private helpers (buildSearchSpec,
runSearch, countUndatedForFilter, isPureTextRelevance) no longer carry a
positional 10-field filter list. The controller builds the record after
its existing tagOp/undated coercions; the density path adapts its
DensityFilters into a SearchFilters at the shared buildSearchSpec call.

The forced-undated count path is preserved via filters.withUndated(true),
so countUndatedForFilter still ignores the user's toggle (#668) while
runSearch honours it. No behaviour change.

Controller binding tests swap their positional any()/eq() matchers for
ArgumentCaptor<SearchFilters>, asserting captured.undated()/.status()/
.sender() — strictly stronger than the previous any()-soup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-31 15:20:13 +02:00
parent 1c961619f1
commit dcb57ffacd
8 changed files with 167 additions and 151 deletions

View File

@@ -316,7 +316,8 @@ public class DocumentController {
@RequestParam(required = false) Boolean undated, @RequestParam(required = false) Boolean undated,
Authentication authentication) { Authentication authentication) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; 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) { if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_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) // 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. // defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; 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); 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) @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. */ /** Loads matching documents and projects to non-null {@link LocalDate}s. */
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) { private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
boolean hasFts = ftsIds != null; boolean hasFts = ftsIds != null;
Specification<Document> spec = buildSearchSpec( SearchFilters searchFilters = new SearchFilters(
hasFts, ftsIds, null, null, filters.text(), null, null, filters.sender(), filters.receiver(),
filters.sender(), filters.receiver(), filters.tags(), filters.tagQ(), filters.status(), filters.tagOperator(), false);
filters.tags(), filters.tagQ(), Specification<Document> spec = buildSearchSpec(hasFts, ftsIds, searchFilters);
filters.status(), filters.tagOperator(), false);
return documentRepository.findAll(spec).stream() return documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate) .map(Document::getDocumentDate)
.filter(Objects::nonNull) .filter(Objects::nonNull)
@@ -500,18 +499,15 @@ public class DocumentService {
* round-trip. * round-trip.
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, public List<UUID> findIdsForFilter(SearchFilters filters) {
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator, boolean hasText = StringUtils.hasText(filters.text());
boolean undated) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
if (hasText) { if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(text); rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
if (rankedIds.isEmpty()) return List.of(); if (rankedIds.isEmpty()) return List.of();
} }
Specification<Document> spec = buildSearchSpec( Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
return documentRepository.findAll(spec).stream().map(Document::getId).toList(); 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 * (uncapped, ID-only). Caller does its own FTS short-circuit when the
* full-text query returned no rows. * full-text query returned no rows.
*/ */
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
LocalDate from, LocalDate to, boolean useOrLogic = filters.tagOperator() == TagOperator.OR;
UUID sender, UUID receiver, List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(filters.tags());
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator,
boolean undated) {
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null; Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
return Specification.where(textSpec) return Specification.where(textSpec)
.and(isBetween(from, to)) .and(isBetween(filters.from(), filters.to()))
.and(hasSender(sender)) .and(hasSender(filters.sender()))
.and(hasReceiver(receiver)) .and(hasReceiver(filters.receiver()))
.and(hasTags(expandedTagSets, useOrLogic)) .and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ)) .and(hasTagPartial(filters.tagQ()))
.and(hasStatus(status)) .and(hasStatus(filters.status()))
.and(undatedOnly(undated)); .and(undatedOnly(filters.undated()));
} }
/** /**
@@ -666,8 +657,8 @@ public class DocumentService {
} }
// 1. Allgemeine Suche (für das Suchfeld im Frontend) // 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) { public DocumentSearchResult searchDocuments(SearchFilters filters, DocumentSort sort, String dir, Pageable pageable) {
boolean hasText = StringUtils.hasText(text); boolean hasText = StringUtils.hasText(filters.text());
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip // Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any // 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 // 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 // FTS hits already folded into the ranked page, so there is no separate undated
// count to report here. // count to report here.
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) { if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
return relevanceSortedPageFromSql(text, pageable); return relevanceSortedPageFromSql(filters.text(), pageable);
} }
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
if (hasText) { if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(text); rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
// FTS matched nothing → no results and, by definition, no undated matches either. // FTS matched nothing → no results and, by definition, no undated matches either.
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); 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), // Global undated count for the current filter (q/tags/sender/receiver/status),
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so // 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). // 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); .withUndatedCount(undatedCount);
} }
/** /**
* Counts every undated document (meta_date IS NULL) matching the active filter, * Counts every undated document (meta_date IS NULL) matching the active filter,
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec} * across all pages, independent of the undated toggle. The caller passes
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status. * {@code filters.withUndated(true)} so the count tracks q/tags/sender/receiver/status
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668), * regardless of the user's "Nur undatierte" toggle. A {@code from}/{@code to} range
* so the count is legitimately 0 inside a date 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, private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
LocalDate from, LocalDate to, UUID sender, UUID receiver, Specification<Document> undatedSpec = buildSearchSpec(hasText, ftsIds, filters);
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
Specification<Document> undatedSpec = buildSearchSpec(
hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true);
return documentRepository.count(undatedSpec); return documentRepository.count(undatedSpec);
} }
/** The original search dispatch — produces the page slice + totals, sans undated count. */ /** The original search dispatch — produces the page slice + totals, sans undated count. */
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds, private DocumentSearchResult runSearch(boolean hasText, List<UUID> rankedIds, SearchFilters filters,
LocalDate from, LocalDate to, UUID sender, UUID receiver, DocumentSort sort, String dir, Pageable pageable) {
List<String> tags, String tagQ, DocumentStatus status,
DocumentSort sort, String dir, TagOperator tagOperator,
boolean undated, Pageable pageable) {
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments) // The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008). // before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
Specification<Document> spec = buildSearchSpec( Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated); String text = filters.text();
// SENDER and RECEIVER sorts load the full match set and slice in-memory. // 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 // 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()); return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
} }
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, SearchFilters filters) {
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status) {
return hasText && (sort == null || sort == DocumentSort.RELEVANCE) return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
&& from == null && to == null && sender == null && receiver == null && filters.from() == null && filters.to() == null
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == 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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -76,7 +75,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_returns200_whenAuthenticated() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
@@ -88,7 +87,7 @@ class DocumentControllerTest {
void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception { void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception {
// The read GET must stay reachable for READ_ALL users — guards against a // The read GET must stay reachable for READ_ALL users — guards against a
// future refactor accidentally write-guarding the undated triage path (#668). // 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("undated", "true")) mockMvc.perform(get("/api/documents/search").param("undated", "true"))
@@ -104,41 +103,43 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception { void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception {
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class); ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("undated", "true")) mockMvc.perform(get("/api/documents/search").param("undated", "true"))
.andExpect(status().isOk()); .andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any()); verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(undatedCaptor.getValue()).isTrue(); assertThat(filtersCaptor.getValue().undated()).isTrue();
} }
@Test @Test
@WithMockUser @WithMockUser
void search_withoutUndatedParam_forwardsFalseToService() throws Exception { void search_withoutUndatedParam_forwardsFalseToService() throws Exception {
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class); ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk()); .andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any()); verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(undatedCaptor.getValue()).isFalse(); assertThat(filtersCaptor.getValue().undated()).isFalse();
} }
@Test @Test
@WithMockUser @WithMockUser
void search_withStatusParam_passesItToService() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk()); .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 @Test
@@ -165,7 +166,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_responseContainsTotalCount() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
@@ -180,7 +181,7 @@ class DocumentControllerTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData( var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); "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( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, 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 { void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of()); 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( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, null, null, DatePrecision.UNKNOWN, null, null,
@@ -223,7 +224,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_responseExposesPagingFields() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
@@ -268,7 +269,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_passesPageRequestToService() throws Exception { 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())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25")) 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<org.springframework.data.domain.Pageable> captor =
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class); 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.springframework.data.domain.Pageable pageable = captor.getValue();
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2); org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25); org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
@@ -1208,7 +1209,7 @@ class DocumentControllerTest {
void getDocumentIds_returns200_andDelegatesToService() throws Exception { void getDocumentIds_returns200_andDelegatesToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID(); 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)); .thenReturn(List.of(id));
mockMvc.perform(get("/api/documents/ids")) mockMvc.perform(get("/api/documents/ids"))
@@ -1221,13 +1222,15 @@ class DocumentControllerTest {
void getDocumentIds_passesSenderIdParamToService() throws Exception { void getDocumentIds_passesSenderIdParamToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID senderId = UUID.randomUUID(); 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()); .thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString())) mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
.andExpect(status().isOk()); .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 @Test
@@ -1237,7 +1240,7 @@ class DocumentControllerTest {
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000). // Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001); java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID()); 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); .thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids")) 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)); savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20)); DocumentSort.RECEIVER, "asc", PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0); assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() -> assertThatCode(() ->
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); })) 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)); savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
assertThatCode(() -> documentService.searchDocuments( assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20))) DocumentSort.SENDER, "asc", PageRequest.of(0, 20)))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
} }

View File

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

View File

@@ -61,8 +61,8 @@ class DocumentSearchPagedIntegrationTest {
@Test @Test
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() { void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(result.items()).hasSize(50); assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -74,8 +74,8 @@ class DocumentSearchPagedIntegrationTest {
@Test @Test
void search_lastPartialPage_returnsRemainingItems() { void search_lastPartialPage_returnsRemainingItems() {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50)); DocumentSort.DATE, "DESC", PageRequest.of(2, 50));
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail. // Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
assertThat(result.items()).hasSize(20); assertThat(result.items()).hasSize(20);
@@ -86,8 +86,8 @@ class DocumentSearchPagedIntegrationTest {
@Test @Test
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() { void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50)); DocumentSort.DATE, "DESC", PageRequest.of(99, 50));
assertThat(result.items()).isEmpty(); assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -99,8 +99,8 @@ class DocumentSearchPagedIntegrationTest {
// comment in DocumentService). Proves that the in-memory slice path // comment in DocumentService). Proves that the in-memory slice path
// returns the correct total from a real repository fetch. // returns the correct total from a real repository fetch.
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.SENDER, "asc", null, false, PageRequest.of(1, 50)); DocumentSort.SENDER, "asc", PageRequest.of(1, 50));
assertThat(result.items()).hasSize(50); assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -125,8 +125,8 @@ class DocumentSearchPagedIntegrationTest {
} }
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
// Global undated count is the full undated total, independent of page size. // Global undated count is the full undated total, independent of page size.
assertThat(result.undatedCount()).isEqualTo(undatedTotal); assertThat(result.undatedCount()).isEqualTo(undatedTotal);
@@ -153,11 +153,11 @@ class DocumentSearchPagedIntegrationTest {
} }
DocumentSearchResult unfiltered = documentService.searchDocuments( DocumentSearchResult unfiltered = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
DocumentSearchResult undatedOnly = documentService.searchDocuments( DocumentSearchResult undatedOnly = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, true),
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50)); DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal); assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal); assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
@@ -178,9 +178,9 @@ class DocumentSearchPagedIntegrationTest {
} }
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31), new SearchFilters(null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
null, null, null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
assertThat(result.undatedCount()).isZero(); assertThat(result.undatedCount()).isZero();
} }
@@ -188,11 +188,11 @@ class DocumentSearchPagedIntegrationTest {
@Test @Test
void search_differentPagesReturnDisjointSlices() { void search_differentPagesReturnDisjointSlices() {
DocumentSearchResult page0 = documentService.searchDocuments( DocumentSearchResult page0 = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
DocumentSearchResult page1 = documentService.searchDocuments( DocumentSearchResult page1 = documentService.searchDocuments(
null, null, null, null, null, null, null, null, new SearchFilters(null, null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50)); DocumentSort.DATE, "DESC", PageRequest.of(1, 50));
// No document id should appear on both pages — slicing must be exclusive. // No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream() var idsOnPage0 = page0.items().stream()

View File

@@ -67,7 +67,8 @@ class DocumentServiceSortTest {
.thenReturn(new PageImpl<>(List.of(newer, older))); .thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments( 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()).hasSize(2);
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
@@ -84,7 +85,8 @@ class DocumentServiceSortTest {
.thenReturn(List.of(doc(id1))); .thenReturn(List.of(doc(id1)));
documentService.searchDocuments( 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).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString()); 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 when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments( 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); 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))); when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments( 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); 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); Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, null, false, hugePage); DocumentSort.RELEVANCE, null, hugePage);
assertThat(result.items()).isEmpty(); assertThat(result.items()).isEmpty();
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt()); verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
@@ -152,8 +156,8 @@ class DocumentServiceSortTest {
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId))); when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, null, false, PAGE); DocumentSort.RELEVANCE, null, PAGE);
assertThat(result.items()).hasSize(1); assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).id()).isEqualTo(uuidId); assertThat(result.items().get(0).id()).isEqualTo(uuidId);
@@ -173,7 +177,8 @@ class DocumentServiceSortTest {
// sender filter is active → triggers in-memory path, not findFtsPageRaw // sender filter is active → triggers in-memory path, not findFtsPageRaw
LocalDate from = LocalDate.of(1900, 1, 1); LocalDate from = LocalDate.of(1900, 1, 1);
documentService.searchDocuments( 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, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository).findAllMatchingIdsByFts("Brief"); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50)); 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).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)); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25)); 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()); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getPageNumber()).isEqualTo(3); 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))) 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)); .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, DocumentSearchResult result = documentService.searchDocuments(
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 50)); 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.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isZero(); assertThat(result.pageNumber()).isZero();
@@ -1486,8 +1489,9 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(
DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5)); 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()); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate"); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(
DocumentSort.DATE, "ASC", null, false, org.springframework.data.domain.PageRequest.of(0, 5)); 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()); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate"); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, documentService.searchDocuments(
DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5)); 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()); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getSort()) assertThat(captor.getValue().getSort())
@@ -1554,8 +1560,9 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all); .thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, DocumentSearchResult result = documentService.searchDocuments(
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50)); 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.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1); assertThat(result.pageNumber()).isEqualTo(1);
@@ -1578,8 +1585,9 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all); .thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, DocumentSearchResult result = documentService.searchDocuments(
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50)); 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.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(30L); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .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)); 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of())); .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)); 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)); .thenReturn(List.of(withSender, noSender));
DocumentSearchResult result = documentService.searchDocuments( 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()).hasSize(2);
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender"); assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
@@ -1700,7 +1711,8 @@ class DocumentServiceTest {
.thenReturn(List.of(noReceivers, withReceiver)); .thenReturn(List.of(noReceivers, withReceiver));
DocumentSearchResult result = documentService.searchDocuments( 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) assertThat(result.items()).extracting(DocumentListItem::title)
.containsExactly("Has Receiver", "No Receivers"); .containsExactly("Has Receiver", "No Receivers");
@@ -1733,7 +1745,8 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna)); .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
DocumentSearchResult result = documentService.searchDocuments( 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 // 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; // 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)); .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
DocumentSearchResult result = documentService.searchDocuments( 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. // Anna's group precedes Bob's (DESC by sender); undated stays inside its group.
assertThat(result.items()).extracting(DocumentListItem::title) assertThat(result.items()).extracting(DocumentListItem::title)
@@ -1787,7 +1801,8 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedFromAlice)); .thenReturn(List.of(undatedFromAlice));
DocumentSearchResult result = documentService.searchDocuments( 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 // The in-memory path queried via a Specification (built by buildSearchSpec with
// undatedOnly(true)) rather than skipping straight to a sorted findAll. // 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))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of()); .thenReturn(List.of());
documentService.searchDocuments("brief", null, null, null, null, null, null, null, documentService.searchDocuments(
DocumentSort.RELEVANCE, null, null, true, UNPAGED); 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. // The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not.
verify(documentRepository).findAllMatchingIdsByFts("brief"); verify(documentRepository).findAllMatchingIdsByFts("brief");
@@ -1827,7 +1843,8 @@ class DocumentServiceTest {
.thenReturn(List.of(docNullName, docSmith)); .thenReturn(List.of(docNullName, docSmith));
DocumentSearchResult result = documentService.searchDocuments( 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") // null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(DocumentListItem::title) assertThat(result.items()).extracting(DocumentListItem::title)
@@ -1850,7 +1867,8 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( 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); assertThat(result.items()).hasSize(1);
SearchMatchData md = result.items().get(0).matchData(); SearchMatchData md = result.items().get(0).matchData();
@@ -1864,7 +1882,8 @@ class DocumentServiceTest {
.thenReturn(new PageImpl<>(List.of())); .thenReturn(new PageImpl<>(List.of()));
DocumentSearchResult result = documentService.searchDocuments( 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(); assertThat(result.items()).isEmpty();
} }
@@ -1884,7 +1903,8 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( 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(); SearchMatchData md = result.items().get(0).matchData();
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
@@ -2401,7 +2421,7 @@ class DocumentServiceTest {
.thenReturn(List.of(d1, d2)); .thenReturn(List.of(d1, d2));
List<UUID> result = documentService.findIdsForFilter( 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()); assertThat(result).containsExactly(d1.getId(), d2.getId());
} }
@@ -2416,7 +2436,7 @@ class DocumentServiceTest {
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
documentService.findIdsForFilter( 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 // Spec built without throwing → OR branch was exercised. Coverage gain
// is in not-throwing on the OR-specific code path; the actual SQL is // 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()); when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter( 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(); assertThat(result).isEmpty();
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class)); verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));