From bdac5e42ad98b264cbb97b6d44e68ec7a153e661 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 10:55:51 +0200 Subject: [PATCH] =?UTF-8?q?test(search):=20integration=20test=20covers=20p?= =?UTF-8?q?aged=20search=20against=20real=20Postgres=20=E2=80=94=20address?= =?UTF-8?q?=20@saraholt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeds 120 UPLOADED docs with a deterministic date spread and runs DocumentService.searchDocuments against a Testcontainers Postgres, not a Mockito mock. Five cases: 1. First page returns exactly page_size items + correct totalElements 2. Last partial page returns the tail slice (offset 100 → 20 items) 3. Page beyond last returns empty content, totalElements still 120 4. SENDER sort path slices in-memory + reports correct total 5. Different pages return disjoint document id sets Closes the integration-coverage gap between the Mockito unit tests and the full Spec→Pageable→Page→DTO path that unit tests can't exercise. Runs in ~87 s against the shared Testcontainers instance. (#316) Co-Authored-By: Claude Opus 4.7 --- .../DocumentSearchPagedIntegrationTest.java | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/DocumentSearchPagedIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentSearchPagedIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentSearchPagedIntegrationTest.java new file mode 100644 index 00000000..5bf02c6b --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentSearchPagedIntegrationTest.java @@ -0,0 +1,137 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.dto.DocumentSearchResult; +import org.raddatz.familienarchiv.dto.DocumentSort; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the + * Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120 + * UPLOADED documents and asserts the slice/total/totalPages arithmetic holds + * against the actual JPA query. + * + *

Closes the integration-coverage gap Sara flagged on PR #316. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class DocumentSearchPagedIntegrationTest { + + private static final int FIXTURE_SIZE = 120; + + @MockitoBean S3Client s3Client; + @Autowired DocumentService documentService; + @Autowired DocumentRepository documentRepository; + + @BeforeEach + void seed() { + // Deterministic date spread so DATE-DESC order is predictable: + // document #0 has the oldest date, document #119 has the newest. + for (int i = 0; i < FIXTURE_SIZE; i++) { + Document doc = Document.builder() + .title("Dok-" + String.format("%03d", i)) + .originalFilename("dok-" + i + ".pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(LocalDate.of(1900, 1, 1).plusDays(i)) + .build(); + documentRepository.save(doc); + } + assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE); + } + + @Test + void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() { + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, + PageRequest.of(0, 50)); + + assertThat(result.items()).hasSize(50); + assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); + assertThat(result.pageNumber()).isZero(); + assertThat(result.pageSize()).isEqualTo(50); + assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50) + } + + @Test + void search_lastPartialPage_returnsRemainingItems() { + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, + PageRequest.of(2, 50)); + + // Page 2 (offset 100) of 120 docs → exactly 20 items on the tail. + assertThat(result.items()).hasSize(20); + assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); + assertThat(result.pageNumber()).isEqualTo(2); + } + + @Test + void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() { + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, + PageRequest.of(99, 50)); + + assertThat(result.items()).isEmpty(); + assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); + } + + @Test + void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() { + // SENDER sort path fetches all + sorts + slices in-memory (see scaling + // comment in DocumentService). Proves that the in-memory slice path + // returns the correct total from a real repository fetch. + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.SENDER, "asc", null, + PageRequest.of(1, 50)); + + assertThat(result.items()).hasSize(50); + assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE); + assertThat(result.pageNumber()).isEqualTo(1); + assertThat(result.totalPages()).isEqualTo(3); + } + + @Test + void search_differentPagesReturnDisjointSlices() { + DocumentSearchResult page0 = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, + PageRequest.of(0, 50)); + DocumentSearchResult page1 = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, + PageRequest.of(1, 50)); + + // No document id should appear on both pages — slicing must be exclusive. + var idsOnPage0 = page0.items().stream() + .map(item -> item.document().getId()) + .toList(); + var idsOnPage1 = page1.items().stream() + .map(item -> item.document().getId()) + .toList(); + for (UUID id : idsOnPage0) { + assertThat(idsOnPage1).doesNotContain(id); + } + } +}