test(search): integration test covers paged search against real Postgres — address @saraholt
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 <noreply@anthropic.com>
This commit was merged in pull request #316.
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user