feat(documents): paginate /documents search so first paint isn't 1500 rows #316

Merged
marcel merged 8 commits from feat/issue-315-paginate-documents-search into main 2026-04-24 13:20:26 +02:00
Showing only changes of commit 4f741a8701 - Show all commits

View File

@@ -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.
*
* <p>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);
}
}
}