diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentFtsPagedIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentFtsPagedIntegrationTest.java
new file mode 100644
index 00000000..122fa4e6
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentFtsPagedIntegrationTest.java
@@ -0,0 +1,109 @@
+package org.raddatz.familienarchiv.document;
+
+import jakarta.persistence.EntityManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.config.FlywayConfig;
+import org.raddatz.familienarchiv.document.DocumentRepository;
+import org.raddatz.familienarchiv.document.Document;
+import org.raddatz.familienarchiv.document.DocumentStatus;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.annotation.DirtiesContext;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+/**
+ * Repository-level integration tests for {@code findFtsPageRaw}: verifies that the
+ * paginated FTS query returns exactly page-size rows and that the window-function
+ * total reflects the full match count, not just the page count.
+ *
+ *
Uses real Postgres via Testcontainers so the GIN index, tsvector trigger, and
+ * {@code websearch_to_tsquery} semantics are identical to production.
+ *
+ *
{@code AFTER_CLASS} dirty-context keeps the Spring context alive for all tests
+ * in this class and rebuilds it once at the end, rather than after every test.
+ */
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Import({PostgresContainerConfig.class, FlywayConfig.class})
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class DocumentFtsPagedIntegrationTest {
+
+ @Autowired DocumentRepository documentRepository;
+ @Autowired EntityManager em;
+
+ // 60 docs match "Walter"; 10 docs with "Hans" do not.
+ private static final int WALTER_COUNT = 60;
+ private static final int PAGE_SIZE = 50;
+
+ @BeforeEach
+ void seed() {
+ documentRepository.deleteAll();
+ em.flush();
+ for (int i = 0; i < WALTER_COUNT; i++) {
+ documentRepository.saveAndFlush(doc("Brief von Walter Nr. " + i));
+ }
+ for (int i = 0; i < 10; i++) {
+ documentRepository.saveAndFlush(doc("Brief von Hans Nr. " + i));
+ }
+ em.clear();
+ }
+
+ @Test
+ void findFtsPageRaw_firstPage_returnsPageSizeRows() {
+ List rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
+
+ assertThat(rows).hasSize(PAGE_SIZE);
+ }
+
+ @Test
+ void findFtsPageRaw_windowTotal_equalsFullMatchCount_notPageSize() {
+ List rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
+
+ long total = ((Number) rows.get(0)[2]).longValue();
+ assertThat(total).isEqualTo(WALTER_COUNT);
+ }
+
+ @Test
+ void findFtsPageRaw_lastPage_returnsRemainder() {
+ int remainder = WALTER_COUNT % PAGE_SIZE; // 60 % 50 = 10
+ List rows = documentRepository.findFtsPageRaw("Walter", PAGE_SIZE, PAGE_SIZE);
+
+ assertThat(rows).hasSize(remainder);
+ long total = ((Number) rows.get(0)[2]).longValue();
+ assertThat(total).isEqualTo(WALTER_COUNT);
+ }
+
+ @Test
+ void findFtsPageRaw_noMatches_returnsEmptyList() {
+ List rows = documentRepository.findFtsPageRaw("XYZ_KEIN_TREFFER", 0, PAGE_SIZE);
+
+ assertThat(rows).isEmpty();
+ }
+
+ @Test
+ void findFtsPageRaw_stopwordOnlyQuery_returnsEmptyList_noException() {
+ assertThatNoException().isThrownBy(() -> {
+ List rows = documentRepository.findFtsPageRaw("der die das und", 0, PAGE_SIZE);
+ assertThat(rows).isEmpty();
+ });
+ }
+
+ // ─── Helper ───────────────────────────────────────────────────────────────
+
+ private Document doc(String title) {
+ return Document.builder()
+ .title(title)
+ .originalFilename(title.replace(" ", "_") + ".pdf")
+ .status(DocumentStatus.UPLOADED)
+ .build();
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java
index 5bff7363..c2f703cf 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java
@@ -21,17 +21,22 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DocumentServiceSortTest {
- private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
+ private static final Pageable PAGE = org.springframework.data.domain.PageRequest.of(0, 10_000);
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@@ -43,12 +48,12 @@ class DocumentServiceSortTest {
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@InjectMocks DocumentService documentService;
- // ─── searchDocuments — DATE sort ──────────────────────────────────────────
+ // ─── DATE sort ────────────────────────────────────────────────────────────
@Test
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
- UUID id1 = UUID.randomUUID(); // rank position 0 (higher relevance, older doc)
- UUID id2 = UUID.randomUUID(); // rank position 1 (lower relevance, newer doc)
+ UUID id1 = UUID.randomUUID(); // higher relevance, older doc
+ UUID id2 = UUID.randomUUID(); // lower relevance, newer doc
Document older = Document.builder().id(id1)
.title("Brief").status(DocumentStatus.UPLOADED)
@@ -57,38 +62,48 @@ class DocumentServiceSortTest {
.title("Brief").status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1960, 1, 1)).build();
- // FTS returns id1 first (higher rank), id2 second
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
- // findAll(spec, pageable) — the correct date path — returns date-DESC order
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments(
- "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
+ "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
- // Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result.items()).hasSize(2);
- assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
+ assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
}
- // ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
+ // ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
+
+ @Test
+ void searchDocuments_relevance_pureText_calls_findFtsPageRaw_not_findAllMatchingIds() {
+ UUID id1 = UUID.randomUUID();
+ List ftsRows = ftsRows(id1, 0.5d, 1L);
+ when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
+ when(documentRepository.findAllById(any()))
+ .thenReturn(List.of(doc(id1)));
+
+ documentService.searchDocuments(
+ "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
+
+ verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
+ verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
+ }
@Test
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
- UUID id1 = UUID.randomUUID(); // rank position 0
- UUID id2 = UUID.randomUUID(); // rank position 1
+ UUID id1 = UUID.randomUUID(); // higher rank — must appear first
+ UUID id2 = UUID.randomUUID(); // lower rank
- Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
- Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
-
- when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
- when(documentRepository.findAll(any(Specification.class)))
- .thenReturn(List.of(doc2, doc1)); // unordered from DB
+ List ftsRows = new ArrayList<>();
+ ftsRows.add(new Object[]{id1, 0.8d, 2L});
+ ftsRows.add(new Object[]{id2, 0.3d, 2L});
+ when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
+ 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, UNPAGED);
+ "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
- // Expect: rank order restored (id1 first)
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
@@ -97,16 +112,47 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
- Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
- Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
-
- when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
- when(documentRepository.findAll(any(Specification.class)))
- .thenReturn(List.of(doc2, doc1));
+ List ftsRows = new ArrayList<>();
+ ftsRows.add(new Object[]{id1, 0.8d, 2L});
+ ftsRows.add(new Object[]{id2, 0.3d, 2L});
+ when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
+ 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, UNPAGED);
+ "Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
+
+ // ─── RELEVANCE sort — text + active filter ────────────────────────────────
+
+ @Test
+ void searchDocuments_relevance_with_active_filter_uses_inMemory_path() {
+ UUID id1 = UUID.randomUUID();
+ UUID id2 = UUID.randomUUID();
+
+ when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
+ when(documentRepository.findAll(any(Specification.class)))
+ .thenReturn(List.of(doc(id2), doc(id1)));
+
+ // sender filter is active → triggers in-memory path, not findFtsPageRaw
+ LocalDate from = LocalDate.of(1900, 1, 1);
+ documentService.searchDocuments(
+ "Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
+
+ verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
+ verify(documentRepository).findAllMatchingIdsByFts("Brief");
+ }
+
+ // ─── Helpers ──────────────────────────────────────────────────────────────
+
+ private static Document doc(UUID id) {
+ return Document.builder().id(id).title("Brief").status(DocumentStatus.UPLOADED).build();
+ }
+
+ private static List ftsRows(UUID id, double rank, long total) {
+ List rows = new ArrayList<>();
+ rows.add(new Object[]{id, rank, total});
+ return rows;
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java
index 9be822b1..35b7a912 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java
@@ -1620,9 +1620,10 @@ class DocumentServiceTest {
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
List rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
- when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(docId));
- when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
- .thenReturn(List.of(doc));
+ List ftsRows = new java.util.ArrayList<>();
+ ftsRows.add(new Object[]{docId, 0.5d, 1L});
+ when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
+ when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -1654,9 +1655,10 @@ class DocumentServiceTest {
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
List rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
- when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(docId));
- when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
- .thenReturn(List.of(doc));
+ List ftsRows2 = new java.util.ArrayList<>();
+ ftsRows2.add(new Object[]{docId, 0.5d, 1L});
+ when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows2);
+ when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(