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); + } + } +}