diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java
index 4d13363b..21fced9d 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java
@@ -3,14 +3,10 @@ package org.raddatz.familienarchiv.geschichte;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.security.SecurityConfig;
-import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
-import org.raddatz.familienarchiv.geschichte.Geschichte;
-import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
-import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -21,6 +17,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
@@ -30,13 +27,13 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -64,8 +61,8 @@ class GeschichteControllerTest {
@Test
@WithMockUser(authorities = "READ_ALL")
void list_returns200_forReader() throws Exception {
- when(geschichteService.list(any(), any(), any(), anyInt()))
- .thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
+ when(geschichteService.list(any(), any(), anyInt()))
+ .thenReturn(List.of(summaryStub("Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
@@ -76,13 +73,13 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
UUID personId = UUID.randomUUID();
- when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
+ when(geschichteService.list(any(), eq(List.of(personId)), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
.andExpect(status().isOk());
- verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
+ verify(geschichteService).list(any(), eq(List.of(personId)), anyInt());
}
@Test
@@ -90,7 +87,7 @@ class GeschichteControllerTest {
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
- when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
+ when(geschichteService.list(any(), eq(List.of(a, b)), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten")
@@ -98,7 +95,7 @@ class GeschichteControllerTest {
.param("personId", b.toString()))
.andExpect(status().isOk());
- verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
+ verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@@ -220,7 +217,7 @@ class GeschichteControllerTest {
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
- .documents(new HashSet<>())
+ .items(new ArrayList<>())
.build();
}
@@ -232,7 +229,20 @@ class GeschichteControllerTest {
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
- .documents(new HashSet<>())
+ .items(new ArrayList<>())
.build();
}
+
+ /** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
+ private GeschichteSummary summaryStub(String title) {
+ return new GeschichteSummary() {
+ public UUID getId() { return UUID.randomUUID(); }
+ public String getTitle() { return title; }
+ public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
+ public GeschichteType getType() { return GeschichteType.STORY; }
+ public AuthorSummary getAuthor() { return null; }
+ public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
+ public String getBody() { return null; }
+ };
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java
new file mode 100644
index 00000000..025f5296
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java
@@ -0,0 +1,227 @@
+package org.raddatz.familienarchiv.geschichte;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
+import org.raddatz.familienarchiv.user.AppUser;
+import org.raddatz.familienarchiv.user.AppUserRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.client.DefaultResponseErrorHandler;
+import org.springframework.web.client.RestTemplate;
+import software.amazon.awssdk.services.s3.S3Client;
+
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
+ *
+ *
No {@code @Transactional} at class level — that would keep a session open and
+ * mask LazyInitializationException caused by open-in-view: false. Each test seeds data
+ * directly via repositories and relies on the service's own transaction boundaries.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+@Import(PostgresContainerConfig.class)
+class GeschichteHttpTest {
+
+ @LocalServerPort int port;
+ @MockitoBean S3Client s3Client;
+
+ @Autowired GeschichteRepository geschichteRepository;
+ @Autowired AppUserRepository appUserRepository;
+ @Autowired PasswordEncoder passwordEncoder;
+
+ private RestTemplate http;
+ private String baseUrl;
+
+ private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
+ private static final String WRITER_PASSWORD = "pass!Geschichte1";
+
+ @BeforeEach
+ void setUp() {
+ http = noThrowRestTemplate();
+ baseUrl = "http://localhost:" + port;
+ geschichteRepository.deleteAll();
+ appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
+ appUserRepository.save(AppUser.builder()
+ .email(WRITER_EMAIL)
+ .password(passwordEncoder.encode(WRITER_PASSWORD))
+ .build());
+ }
+
+ // ─── GET /api/geschichten ────────────────────────────────────────────────
+
+ @Test
+ void list_returns_200_and_empty_array_when_no_stories_exist() {
+ String session = loginAsWriter();
+ ResponseEntity response = http.exchange(
+ baseUrl + "/api/geschichten", HttpMethod.GET,
+ new HttpEntity<>(sessionHeaders(session)), String.class);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).isEqualTo("[]");
+ }
+
+ @Test
+ void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
+ // Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
+ // Hibernate.initialize in getById() this would 500. list() uses a projection so it
+ // must also never touch items.
+ AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
+ Geschichte journey = Geschichte.builder()
+ .title("Reise durch die Briefe")
+ .status(GeschichteStatus.PUBLISHED)
+ .type(GeschichteType.JOURNEY)
+ .author(writer)
+ .publishedAt(LocalDateTime.now())
+ .items(new ArrayList<>())
+ .persons(new HashSet<>())
+ .build();
+ JourneyItem item = JourneyItem.builder()
+ .geschichte(journey)
+ .position(1000)
+ .note("Einleitung")
+ .build();
+ journey.getItems().add(item);
+ geschichteRepository.save(journey);
+
+ String session = loginAsWriter();
+ ResponseEntity response = http.exchange(
+ baseUrl + "/api/geschichten", HttpMethod.GET,
+ new HttpEntity<>(sessionHeaders(session)), String.class);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).contains("Reise durch die Briefe");
+ }
+
+ // ─── GET /api/geschichten/{id} ───────────────────────────────────────────
+
+ @Test
+ void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
+ // This test is the canonical guard against LazyInitializationException.
+ // open-in-view: false means the Hibernate session is closed when Jackson serializes.
+ // GeschichteService.getById() must initialize items inside its @Transactional boundary.
+ AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
+ Geschichte journey = Geschichte.builder()
+ .title("Familiengeschichte")
+ .status(GeschichteStatus.PUBLISHED)
+ .type(GeschichteType.JOURNEY)
+ .author(writer)
+ .publishedAt(LocalDateTime.now())
+ .items(new ArrayList<>())
+ .persons(new HashSet<>())
+ .build();
+ JourneyItem note = JourneyItem.builder()
+ .geschichte(journey).position(1000).note("Prolog").build();
+ JourneyItem note2 = JourneyItem.builder()
+ .geschichte(journey).position(2000).note("Epilog").build();
+ journey.getItems().add(note);
+ journey.getItems().add(note2);
+ Geschichte saved = geschichteRepository.save(journey);
+
+ String session = loginAsWriter();
+ ResponseEntity response = http.exchange(
+ baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
+ new HttpEntity<>(sessionHeaders(session)), String.class);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody())
+ .contains("Familiengeschichte")
+ .contains("Prolog")
+ .contains("Epilog");
+ }
+
+ @Test
+ void getById_returns_404_for_unknown_id() {
+ String session = loginAsWriter();
+ ResponseEntity response = http.exchange(
+ baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
+ new HttpEntity<>(sessionHeaders(session)), String.class);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(404);
+ assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
+ }
+
+ @Test
+ void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
+ AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
+ Geschichte draft = Geschichte.builder()
+ .title("Geheimer Entwurf")
+ .status(GeschichteStatus.DRAFT)
+ .author(writer)
+ .items(new ArrayList<>())
+ .persons(new HashSet<>())
+ .build();
+ Geschichte saved = geschichteRepository.save(draft);
+
+ // Writer lacks explicit BLOG_WRITE permission in the app_users table,
+ // so from the service's perspective they're a reader.
+ String session = loginAsWriter();
+ ResponseEntity response = http.exchange(
+ baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
+ new HttpEntity<>(sessionHeaders(session)), String.class);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(404);
+ }
+
+ // ─── helpers ─────────────────────────────────────────────────────────────
+
+ private String loginAsWriter() {
+ String xsrf = UUID.randomUUID().toString();
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
+ headers.set("X-XSRF-TOKEN", xsrf);
+ String body = "{\"email\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}";
+ ResponseEntity resp = http.postForEntity(
+ baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
+ return extractFaSessionCookie(resp);
+ }
+
+ private HttpHeaders sessionHeaders(String sessionId) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("Cookie", "fa_session=" + sessionId);
+ return headers;
+ }
+
+ private String extractFaSessionCookie(ResponseEntity> response) {
+ List setCookieHeader = response.getHeaders().get("Set-Cookie");
+ if (setCookieHeader == null) return "";
+ return setCookieHeader.stream()
+ .filter(c -> c.startsWith("fa_session="))
+ .map(c -> c.split(";")[0].substring("fa_session=".length()))
+ .findFirst()
+ .orElse("");
+ }
+
+ private RestTemplate noThrowRestTemplate() {
+ RestTemplate template = new RestTemplate();
+ template.setErrorHandler(new DefaultResponseErrorHandler() {
+ @Override
+ public boolean hasError(ClientHttpResponse response) throws IOException {
+ return false;
+ }
+ });
+ return template;
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java
new file mode 100644
index 00000000..ec0d79a5
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java
@@ -0,0 +1,196 @@
+package org.raddatz.familienarchiv.geschichte;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.config.FlywayConfig;
+import org.raddatz.familienarchiv.person.Person;
+import org.raddatz.familienarchiv.person.PersonRepository;
+import org.raddatz.familienarchiv.user.AppUser;
+import org.raddatz.familienarchiv.user.AppUserRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
+import org.springframework.context.annotation.Import;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Import({PostgresContainerConfig.class, FlywayConfig.class})
+class GeschichteListProjectionTest {
+
+ @Autowired GeschichteRepository geschichteRepository;
+ @Autowired AppUserRepository appUserRepository;
+ @Autowired PersonRepository personRepository;
+
+ AppUser author;
+ AppUser otherAuthor;
+
+ @BeforeEach
+ void setUp() {
+ geschichteRepository.deleteAll();
+ author = appUserRepository.save(AppUser.builder()
+ .email("author@test").password("pw").build());
+ otherAuthor = appUserRepository.save(AppUser.builder()
+ .email("other@test").password("pw").build());
+ }
+
+ // ─── findSummaries returns only the requested status ─────────────────────
+
+ @Test
+ void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
+ geschichteRepository.save(published("Veröffentlicht", author));
+ geschichteRepository.save(draft("Entwurf", author));
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.PUBLISHED, null, sentinel(), 0);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
+ }
+
+ @Test
+ void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
+ geschichteRepository.save(draft("Nur Entwurf", author));
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.PUBLISHED, null, sentinel(), 0);
+
+ assertThat(result).isEmpty();
+ }
+
+ // ─── AuthorSummary nested projection ─────────────────────────────────────
+
+ @Test
+ void findSummaries_exposes_nested_author_firstName_lastName_email() {
+ AppUser richAuthor = appUserRepository.save(AppUser.builder()
+ .email("franz@raddatz.de").password("pw").build());
+ geschichteRepository.save(published("Briefe aus der Front", richAuthor));
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.PUBLISHED, null, sentinel(), 0);
+
+ assertThat(result).hasSize(1);
+ GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
+ assertThat(a.getEmail()).isEqualTo("franz@raddatz.de");
+ }
+
+ // ─── GeschichteType is exposed ────────────────────────────────────────────
+
+ @Test
+ void findSummaries_exposes_type_field() {
+ Geschichte journey = Geschichte.builder()
+ .title("Eine Reise")
+ .status(GeschichteStatus.PUBLISHED)
+ .type(GeschichteType.JOURNEY)
+ .author(author)
+ .publishedAt(LocalDateTime.now())
+ .build();
+ geschichteRepository.save(journey);
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.PUBLISHED, null, sentinel(), 0);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
+ }
+
+ // ─── authorId filter (own-drafts gate) ───────────────────────────────────
+
+ @Test
+ void findSummaries_with_authorId_returns_only_own_drafts() {
+ geschichteRepository.save(draft("Mein Entwurf", author));
+ geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.DRAFT, author.getId(), sentinel(), 0);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
+ }
+
+ // ─── personCount = 0 → no person filter ──────────────────────────────────
+
+ @Test
+ void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
+ geschichteRepository.save(published("A", author));
+ geschichteRepository.save(published("B", author));
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.PUBLISHED, null, sentinel(), 0);
+
+ assertThat(result).hasSize(2);
+ }
+
+ // ─── personCount > 0 AND-semantics ───────────────────────────────────────
+
+ @Test
+ void findSummaries_with_one_personId_returns_only_linked_stories() {
+ Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
+ Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
+
+ Geschichte withFranz = published("Franz story", author);
+ withFranz.getPersons().add(franz);
+ geschichteRepository.save(withFranz);
+
+ Geschichte withAnna = published("Anna story", author);
+ withAnna.getPersons().add(anna);
+ geschichteRepository.save(withAnna);
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
+ }
+
+ @Test
+ void findSummaries_with_two_personIds_uses_AND_semantics() {
+ Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
+ Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
+
+ Geschichte both = published("Both", author);
+ both.getPersons().add(franz);
+ both.getPersons().add(anna);
+ geschichteRepository.save(both);
+
+ Geschichte onlyFranz = published("Only Franz", author);
+ onlyFranz.getPersons().add(franz);
+ geschichteRepository.save(onlyFranz);
+
+ List result = geschichteRepository.findSummaries(
+ GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getTitle()).isEqualTo("Both");
+ }
+
+ // ─── helpers ─────────────────────────────────────────────────────────────
+
+ private Geschichte published(String title, AppUser writer) {
+ return Geschichte.builder()
+ .title(title)
+ .status(GeschichteStatus.PUBLISHED)
+ .author(writer)
+ .publishedAt(LocalDateTime.now())
+ .build();
+ }
+
+ private Geschichte draft(String title, AppUser writer) {
+ return Geschichte.builder()
+ .title(title)
+ .status(GeschichteStatus.DRAFT)
+ .author(writer)
+ .build();
+ }
+
+ /** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
+ private List sentinel() {
+ return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java
index 31c73af1..25a46552 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java
@@ -86,7 +86,7 @@ class GeschichteServiceIntegrationTest {
// Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL);
- assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
+ assertThat(geschichteService.list(null, List.of(), 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, List.of(), null, 50)).hasSize(1);
- assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
+ assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1);
+ assertThat(geschichteService.list(null, List.of(franz.getId()), 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());
@@ -136,26 +136,26 @@ class GeschichteServiceIntegrationTest {
authenticateAs(reader, Permission.READ_ALL);
// No filter → all three
- assertThat(geschichteService.list(null, List.of(), null, 50))
- .extracting(Geschichte::getId)
+ assertThat(geschichteService.list(null, List.of(), 50))
+ .extracting(GeschichteSummary::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three
- assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
- .extracting(Geschichte::getId)
+ assertThat(geschichteService.list(null, List.of(a.getId()), 50))
+ .extracting(GeschichteSummary::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)
+ assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), 50))
+ .extracting(GeschichteSummary::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
- assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
+ assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), 50))
.isEmpty();
// AND: Anna AND Bertha AND Carl → none
- assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
+ assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), 50))
.isEmpty();
}
@@ -174,7 +174,7 @@ class GeschichteServiceIntegrationTest {
geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE);
- List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
+ List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), 50);
assertThat(result).isEmpty();
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java
index 0a51d319..64920730 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java
@@ -7,26 +7,20 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.user.AppUser;
-import org.raddatz.familienarchiv.document.Document;
-import org.raddatz.familienarchiv.geschichte.Geschichte;
-import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
-import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
-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;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -37,7 +31,8 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -128,74 +123,60 @@ class GeschichteServiceTest {
@Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
- when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
- .thenReturn(List.of(published(UUID.randomUUID())));
+ when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
+ .thenReturn(List.of());
- geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
+ geschichteService.list(null, List.of(), 50);
- // 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));
+ verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
- when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
- .thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
+ GeschichteSummary s1 = mock(GeschichteSummary.class);
+ GeschichteSummary s2 = mock(GeschichteSummary.class);
+ when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
+ .thenReturn(List.of(s1, s2));
- List out = geschichteService.list(null, List.of(), null, 50);
+ List out = geschichteService.list(null, List.of(), 50);
assertThat(out).hasSize(2);
- verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
+ verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
- void list_invokes_repository_findAll_when_filtering_by_single_personId() {
+ void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID();
- when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
+ when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
.thenReturn(List.of());
- geschichteService.list(null, List.of(personId), null, 50);
+ geschichteService.list(null, List.of(personId), 50);
- verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
+ verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
- void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
+ void list_invokes_repository_findSummaries_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)))
+ when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
.thenReturn(List.of());
- geschichteService.list(null, List.of(a, b), null, 50);
+ geschichteService.list(null, List.of(a, b), 50);
- verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
+ verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
- void list_filters_by_documentId() {
+ void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
- UUID documentId = UUID.randomUUID();
- when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
- .thenReturn(List.of());
+ when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
+ .thenReturn(List.of(mock(GeschichteSummary.class)));
- geschichteService.list(null, List.of(), documentId, 50);
-
- 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 out = geschichteService.list(null, List.of(), null, 9999);
+ List out = geschichteService.list(null, List.of(), 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@@ -282,25 +263,6 @@ class GeschichteServiceTest {
assertThat(saved.getPersons()).containsExactly(person);
}
- @Test
- void create_resolves_documentIds_via_DocumentService() {
- authenticateAs(writer, Permission.BLOG_WRITE);
- when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
- UUID docId = UUID.randomUUID();
- Document doc = Document.builder().id(docId).build();
- when(documentService.getDocumentById(docId)).thenReturn(doc);
- when(geschichteRepository.save(any(Geschichte.class)))
- .thenAnswer(inv -> inv.getArgument(0));
-
- GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
- dto.setTitle("Linked doc");
- dto.setDocumentIds(List.of(docId));
-
- Geschichte saved = geschichteService.create(dto);
-
- assertThat(saved.getDocuments()).containsExactly(doc);
- }
-
@Test
void create_throws_BAD_REQUEST_when_title_blank() {
authenticateAs(writer, Permission.BLOG_WRITE);
@@ -426,7 +388,7 @@ class GeschichteServiceTest {
.body("body
")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
- .documents(new HashSet<>())
+ .items(new ArrayList<>())
.build();
}
@@ -438,7 +400,7 @@ class GeschichteServiceTest {
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now().minusHours(1))
.persons(new HashSet<>())
- .documents(new HashSet<>())
+ .items(new ArrayList<>())
.build();
}
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java
new file mode 100644
index 00000000..8de9c41e
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java
@@ -0,0 +1,209 @@
+package org.raddatz.familienarchiv.geschichte.journeyitem;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.document.Document;
+import org.raddatz.familienarchiv.document.DocumentRepository;
+import org.raddatz.familienarchiv.document.DocumentStatus;
+import org.raddatz.familienarchiv.geschichte.Geschichte;
+import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
+import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
+import org.raddatz.familienarchiv.geschichte.GeschichteType;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.transaction.annotation.Transactional;
+import software.amazon.awssdk.services.s3.S3Client;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
+@ActiveProfiles("test")
+@Import(PostgresContainerConfig.class)
+@Transactional
+class JourneyItemIntegrationTest {
+
+ @MockitoBean
+ S3Client s3Client;
+
+ @PersistenceContext
+ EntityManager em;
+
+ @Autowired GeschichteRepository geschichteRepository;
+ @Autowired JourneyItemRepository journeyItemRepository;
+ @Autowired DocumentRepository documentRepository;
+
+ Geschichte journey;
+ Document doc;
+
+ @BeforeEach
+ void seed() {
+ doc = documentRepository.save(Document.builder()
+ .title("Testbrief")
+ .originalFilename("testbrief.pdf")
+ .status(DocumentStatus.UPLOADED)
+ .build());
+ journey = geschichteRepository.save(Geschichte.builder()
+ .title("Eine Lesereise")
+ .status(GeschichteStatus.DRAFT)
+ .type(GeschichteType.JOURNEY)
+ .build());
+ em.flush();
+ em.clear();
+ }
+
+ // ─── @OrderBy ─────────────────────────────────────────────────────────────
+
+ @Test
+ void items_are_returned_in_position_order_regardless_of_insertion_order() {
+ Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
+
+ JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
+ JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
+ JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).document(doc).build();
+ managed.getItems().addAll(List.of(third, first, second));
+ geschichteRepository.save(managed);
+ em.flush();
+ em.clear();
+
+ Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
+ List positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
+
+ assertThat(positions).containsExactly(1000, 2000, 3000);
+ }
+
+ // ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
+
+ @Test
+ void deleting_geschichte_cascade_deletes_all_journey_items() {
+ Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
+ managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
+ managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
+ geschichteRepository.save(managed);
+ em.flush();
+ em.clear();
+
+ UUID geschichteId = journey.getId();
+ geschichteRepository.deleteById(geschichteId);
+ em.flush();
+
+ assertThat(journeyItemRepository.findAllByGeschichteId(geschichteId)).isEmpty();
+ }
+
+ @Test
+ void removing_item_from_items_list_triggers_orphan_removal() {
+ Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
+ JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
+ managed.getItems().add(item);
+ Geschichte saved = geschichteRepository.save(managed);
+ em.flush();
+ UUID itemId = saved.getItems().get(0).getId(); // extract before clear
+ em.clear();
+ Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
+ reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
+ geschichteRepository.save(reloaded);
+ em.flush();
+
+ assertThat(journeyItemRepository.findById(itemId)).isEmpty();
+ }
+
+ // ─── GeschichteType round-trip ────────────────────────────────────────────
+
+ @Test
+ void type_persists_as_JOURNEY_and_roundtrips() {
+ Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
+ assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
+ }
+
+ @Test
+ void type_defaults_to_STORY_for_new_geschichten() {
+ Geschichte story = geschichteRepository.save(Geschichte.builder()
+ .title("Erinnerung")
+ .status(GeschichteStatus.DRAFT)
+ .build());
+ em.flush();
+ em.clear();
+
+ Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
+ assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
+ }
+
+ // ─── Note-only item (document_id IS NULL) ─────────────────────────────────
+
+ @Test
+ void note_only_item_persists_without_document() {
+ Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
+ JourneyItem note = JourneyItem.builder()
+ .geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
+ managed.getItems().add(note);
+ Geschichte saved = geschichteRepository.save(managed);
+ em.flush();
+ UUID noteId = saved.getItems().get(0).getId(); // extract before clear
+ em.clear();
+ JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
+ assertThat(reloaded.getDocumentId()).isNull();
+ assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
+ }
+
+ // ─── Document-backed item exposes documentId ──────────────────────────────
+
+ @Test
+ void document_backed_item_exposes_document_uuid_via_getDocumentId() {
+ Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
+ JourneyItem item = JourneyItem.builder()
+ .geschichte(managed).position(1000).document(doc).build();
+ managed.getItems().add(item);
+ Geschichte saved = geschichteRepository.save(managed);
+ em.flush();
+ UUID itemId = saved.getItems().get(0).getId(); // extract before clear
+ em.clear();
+ JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
+ assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
+ }
+
+ // ─── ON DELETE SET NULL ───────────────────────────────────────────────────
+
+ @Test
+ void deleting_document_sets_item_document_to_null_not_delete_item() {
+ Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
+ JourneyItem item = JourneyItem.builder()
+ .geschichte(managed).position(1000).document(doc).note("still here").build();
+ managed.getItems().add(item);
+ Geschichte saved = geschichteRepository.save(managed);
+ em.flush();
+ UUID itemId = saved.getItems().get(0).getId(); // extract before clear
+ em.clear();
+
+ // Delete document — ON DELETE SET NULL fires at DB level
+ documentRepository.deleteById(doc.getId());
+ em.flush();
+ em.clear();
+
+ JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
+ assertThat(surviving.getDocumentId()).isNull();
+ assertThat(surviving.getNote()).isEqualTo("still here");
+ }
+
+ // ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
+
+ @Test
+ void saving_item_with_neither_document_nor_note_violates_check_constraint() {
+ Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
+ JourneyItem empty = JourneyItem.builder()
+ .geschichte(managed).position(1000).build();
+
+ assertThatThrownBy(() -> {
+ journeyItemRepository.save(empty);
+ journeyItemRepository.flush();
+ }).isInstanceOf(Exception.class);
+ }
+}