feat(person): add filter-aware paged repository queries

Add PersonSearchResult (mirrors DocumentSearchResult shape) and PersonFilter
records, plus paired findByFilter/countByFilter native queries sharing one
WHERE clause so the rendered page and totalElements can never drift. Filters
(type, familyOnly, hasDocuments, provisional, readerDefault, q) each disable
via a null/false param. Tested against real Postgres via Testcontainers.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-27 13:27:39 +02:00
parent 09b810afb6
commit a24764e58a
4 changed files with 279 additions and 0 deletions

View File

@@ -505,4 +505,156 @@ class PersonRepositoryTest {
.filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow();
assertThat(summary.isProvisional()).isTrue();
}
// ─── #667: filter-aware paged slice + paired COUNT (Postgres-only) ────────
// The slice query (findByFilter) and the count query (countByFilter) MUST share one
// WHERE clause so totalElements can never drift from the rendered page. These tests run
// against real Postgres because the slice ORDER BY uses a computed alias that fails on H2.
private void seedDirectoryFixture() {
// Register family member, no documents — visible by reader default (familyMember)
personRepository.save(Person.builder().firstName("Karl").lastName("Register").familyMember(true).build());
// Person with one document — visible by reader default (documentCount > 0)
Person hasDoc = personRepository.save(Person.builder().firstName("Doku").lastName("Person").build());
documentRepository.save(Document.builder().title("B").originalFilename("b.pdf")
.status(DocumentStatus.UPLOADED).sender(hasDoc).build());
// Provisional, zero-document, non-family — hidden by reader default
personRepository.save(Person.builder().firstName("Unbe").lastName("Staetigt").provisional(true).build());
// An institution with no documents, non-family, non-provisional
personRepository.save(Person.builder().lastName("Verlag GmbH").personType(PersonType.INSTITUTION).build());
}
@Test
void findByFilter_readerDefault_returnsOnlyFamilyOrWithDocuments() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, true, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName)
.containsExactlyInAnyOrder("Register", "Person");
}
@Test
void countByFilter_readerDefault_matchesSliceSize() {
seedDirectoryFixture();
long count = personRepository.countByFilter(null, null, null, null, true, null);
assertThat(count).isEqualTo(2);
}
@Test
void findByFilter_showAll_returnsEveryone() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, null, 50, 0);
assertThat(slice).hasSize(4);
}
@Test
void findByFilter_typeInstitution_returnsOnlyInstitutions() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
"INSTITUTION", null, null, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
}
@Test
void findByFilter_familyOnly_returnsOnlyFamilyMembers() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, true, null, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Register");
}
@Test
void findByFilter_hasDocuments_returnsOnlyPersonsWithDocuments() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, true, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Person");
}
@Test
void findByFilter_provisionalTrue_returnsOnlyProvisional() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, true, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Staetigt");
}
@Test
void findByFilter_combinedFilters_andTogether() {
seedDirectoryFixture();
// family + has-documents → intersection is empty (Register has no docs, Doku is not family)
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, true, true, null, false, null, 50, 0);
assertThat(slice).isEmpty();
}
@Test
void findByFilter_query_combinesWithFilters() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, "Verlag", 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
}
@Test
void findByFilter_pageBeyondRange_returnsEmptySlice() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, null, 50, 999 * 50);
assertThat(slice).isEmpty();
}
@Test
void findByFilter_respectsPageSize() {
seedDirectoryFixture();
List<PersonSummaryDTO> firstPage = personRepository.findByFilter(
null, null, null, null, false, null, 2, 0);
List<PersonSummaryDTO> secondPage = personRepository.findByFilter(
null, null, null, null, false, null, 2, 2);
assertThat(firstPage).hasSize(2);
assertThat(secondPage).hasSize(2);
assertThat(firstPage).extracting(PersonSummaryDTO::getId)
.doesNotContainAnyElementsOf(secondPage.stream().map(PersonSummaryDTO::getId).toList());
}
@Test
void countByFilter_typeInstitution_matchesSlice() {
seedDirectoryFixture();
long count = personRepository.countByFilter("INSTITUTION", null, null, null, false, null);
assertThat(count).isEqualTo(1);
}
@Test
void findByFilter_projectsDocumentCount() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, true, null, false, null, 50, 0);
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
}
}