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); + } +}