feat(geschichten): filter by multiple persons with AND semantics

GET /api/geschichten now accepts repeated personId query params and
returns only stories that mention every person supplied. Refactors the
list path to a JPA Specification chain (one EXISTS subquery per id,
mirroring DocumentSpecifications.hasTags) and embeds the
COALESCE(publishedAt, updatedAt) DESC ordering inside the spec so a
single repository.findAll covers all filter combinations.
This commit is contained in:
Marcel
2026-05-02 19:17:39 +02:00
parent 2ae830a3c8
commit 0802889ea9
7 changed files with 234 additions and 47 deletions

View File

@@ -73,15 +73,31 @@ class GeschichteControllerTest {
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesPersonIdFilterToService() throws Exception {
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
UUID personId = UUID.randomUUID();
when(geschichteService.list(any(), eq(personId), any(), anyInt()))
when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(personId), any(), anyInt());
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten")
.param("personId", a.toString())
.param("personId", b.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────

View File

@@ -86,7 +86,7 @@ class GeschichteServiceIntegrationTest {
// Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 50)).isEmpty();
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
@@ -102,8 +102,8 @@ class GeschichteServiceIntegrationTest {
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 50)).hasSize(1);
assertThat(geschichteService.list(null, franz.getId(), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
@@ -117,6 +117,57 @@ class GeschichteServiceIntegrationTest {
assertThat(personRepository.findById(franz.getId())).isPresent();
}
@Test
void list_filters_with_AND_semantics_when_multiple_personIds_given() {
// Three published stories, persons overlap so we can prove AND-not-OR:
// story_AB: about A and B
// story_AC: about A and C
// story_A: about A only
authenticateAs(writer, Permission.BLOG_WRITE);
Person a = personRepository.save(Person.builder().firstName("Anna").lastName("A").build());
Person b = personRepository.save(Person.builder().firstName("Bertha").lastName("B").build());
Person c = personRepository.save(Person.builder().firstName("Carl").lastName("C").build());
UUID storyAB = publishedStoryWithPersons("Anna & Bertha", List.of(a.getId(), b.getId()));
UUID storyAC = publishedStoryWithPersons("Anna & Carl", List.of(a.getId(), c.getId()));
UUID storyA = publishedStoryWithPersons("Anna alone", List.of(a.getId()));
authenticateAs(reader, Permission.READ_ALL);
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(Geschichte::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
.extracting(Geschichte::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
.extracting(Geschichte::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
.isEmpty();
// AND: Anna AND Bertha AND Carl → none
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
.isEmpty();
}
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle(title);
dto.setBody("<p>body</p>");
dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).getId();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))

View File

@@ -4,7 +4,6 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -18,7 +17,8 @@ import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -36,7 +36,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -126,47 +125,76 @@ class GeschichteServiceTest {
@Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.search(eq(GeschichteStatus.PUBLISHED), any(), any(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
geschichteService.list(/*status*/ null, /*personId*/ null, /*documentId*/ null, /*limit*/ 50);
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), any(), any(), any());
// Status pinning lives inside the Specification; we assert end-to-end behaviour
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
// through the spec-aware repository method.
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.search(any(), any(), any(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
geschichteService.list(null, null, null, 50);
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).search(eq(null), any(), any(), any());
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_filters_by_personId() {
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID();
when(geschichteRepository.search(any(), eq(personId), any(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, personId, null, 50);
geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), eq(personId), eq(null), any());
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
authenticateAs(reader, Permission.READ_ALL);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_filters_by_documentId() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.search(any(), any(), eq(documentId), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, null, documentId, 50);
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), eq(null), eq(documentId), any());
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
// ─── create ──────────────────────────────────────────────────────────────