From 4a0a43b1cf67d35be947e8f716379ffd287c618e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 14:21:05 +0200 Subject: [PATCH] test(fts): add integration tests and update unit tests for SQL-paginated relevance - DocumentFtsPagedIntegrationTest: Testcontainers repo-level tests for findFtsPageRaw (page size, window total, last page, no matches, stopword) - DocumentServiceSortTest: rewritten to stub findFtsPageRaw + findAllById for the pure-text RELEVANCE path; verifies filter-active path stays in-memory - DocumentServiceTest: update two enrichment tests to use new SQL-path stubs Co-Authored-By: Claude Sonnet 4.6 --- .../DocumentFtsPagedIntegrationTest.java | 109 ++++++++++++++++++ .../document/DocumentServiceSortTest.java | 100 +++++++++++----- .../document/DocumentServiceTest.java | 14 ++- 3 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/document/DocumentFtsPagedIntegrationTest.java 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(