diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java index 09be9493..11468c8d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java @@ -37,7 +37,7 @@ public class GeschichteController { } @GetMapping("/{id}") - public Geschichte getById(@PathVariable UUID id) { + public GeschichteView getById(@PathVariable UUID id) { return geschichteService.getById(id); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index b290186c..37a1b75f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -2,11 +2,12 @@ package org.raddatz.familienarchiv.geschichte; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.Hibernate; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.security.Permission; @@ -33,10 +34,9 @@ public class GeschichteService { private final GeschichteRepository geschichteRepository; private final PersonService personService; - // Reserved for lesereisen-editor: JourneyItem document resolution must go through - // DocumentService.getDocumentById to enforce existence and scope checks. private final DocumentService documentService; private final UserService userService; + private final JourneyItemService journeyItemService; /** * Allow-list policy for Geschichte body HTML. Tiptap on the writer side @@ -57,7 +57,7 @@ public class GeschichteService { } @Transactional(readOnly = true) - public Geschichte getById(UUID id) { + public GeschichteView getById(UUID id) { Geschichte g = geschichteRepository.findById(id) .orElseThrow(() -> DomainException.notFound( ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id)); @@ -66,11 +66,28 @@ public class GeschichteService { throw DomainException.notFound( ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id); } - // Force-initialize LAZY items inside this transaction. - // open-in-view is FALSE — without this touch, Jackson serializes a closed - // Hibernate session and throws LazyInitializationException → HTTP 500. - Hibernate.initialize(g.getItems()); - return g; + // Items loaded via repository query — never through the LAZY collection on Geschichte. + // This keeps open-in-view:false safe without Hibernate.initialize. + List items = journeyItemService.getItems(id); + return toView(g, items); + } + + private GeschichteView toView(Geschichte g, List items) { + AppUser author = g.getAuthor(); + GeschichteView.AuthorView authorView = null; + if (author != null) { + String displayName = ((author.getFirstName() != null ? author.getFirstName() : "") + + " " + (author.getLastName() != null ? author.getLastName() : "")).trim(); + if (displayName.isBlank()) displayName = author.getEmail(); + authorView = new GeschichteView.AuthorView(author.getId(), displayName); + } + return new GeschichteView( + g.getId(), g.getTitle(), g.getBody(), + g.getStatus(), g.getType(), + authorView, g.getPersons(), + items, + g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt() + ); } /** 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 21fced9d..54dc8c85 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -104,7 +104,7 @@ class GeschichteControllerTest { @WithMockUser(authorities = "READ_ALL") void getById_returns200_whenFound() throws Exception { UUID id = UUID.randomUUID(); - when(geschichteService.getById(id)).thenReturn(published(id, "Hello")); + when(geschichteService.getById(id)).thenReturn(viewStub(id, "Hello")); mockMvc.perform(get("/api/geschichten/{id}", id)) .andExpect(status().isOk()) @@ -233,6 +233,13 @@ class GeschichteControllerTest { .build(); } + private GeschichteView viewStub(UUID id, String title) { + return new GeschichteView(id, title, "

x

", + GeschichteStatus.PUBLISHED, GeschichteType.STORY, + null, new HashSet<>(), List.of(), + LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); + } + /** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */ private GeschichteSummary summaryStub(String title) { return new GeschichteSummary() { 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 25a46552..39213aec 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.geschichte.Geschichte; import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.GeschichteView; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.user.AppUserRepository; import org.raddatz.familienarchiv.geschichte.GeschichteRepository; @@ -104,9 +105,9 @@ class GeschichteServiceIntegrationTest { authenticateAs(reader, Permission.READ_ALL); 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()); + GeschichteView fetched = geschichteService.getById(draftId); + assertThat(fetched.title()).isEqualTo("Erinnerung an Opa Franz"); + assertThat(fetched.persons()).extracting(Person::getId).containsExactly(franz.getId()); // Delete as writer; join rows go with it authenticateAs(writer, Permission.BLOG_WRITE); 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 64920730..19c72c15 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -9,6 +9,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.security.Permission; @@ -32,6 +33,7 @@ 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.anyLong; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -40,17 +42,13 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class GeschichteServiceTest { - @Mock - GeschichteRepository geschichteRepository; - @Mock - PersonService personService; - @Mock - DocumentService documentService; - @Mock - UserService userService; + @Mock GeschichteRepository geschichteRepository; + @Mock PersonService personService; + @Mock DocumentService documentService; + @Mock UserService userService; + @Mock JourneyItemService journeyItemService; - @InjectMocks - GeschichteService geschichteService; + @InjectMocks GeschichteService geschichteService; AppUser writer; AppUser reader; @@ -60,6 +58,7 @@ class GeschichteServiceTest { SecurityContextHolder.clearContext(); writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build(); reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build(); + lenient().when(journeyItemService.getItems(any())).thenReturn(List.of()); } @AfterEach @@ -89,9 +88,10 @@ class GeschichteServiceTest { Geschichte draft = draft(id); when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft)); - Geschichte result = geschichteService.getById(id); + GeschichteView result = geschichteService.getById(id); - assertThat(result).isSameAs(draft); + assertThat(result.id()).isEqualTo(id); + assertThat(result.status()).isEqualTo(GeschichteStatus.DRAFT); } @Test @@ -101,9 +101,70 @@ class GeschichteServiceTest { Geschichte published = published(id); when(geschichteRepository.findById(id)).thenReturn(Optional.of(published)); - Geschichte result = geschichteService.getById(id); + GeschichteView result = geschichteService.getById(id); - assertThat(result).isSameAs(published); + assertThat(result.id()).isEqualTo(id); + assertThat(result.status()).isEqualTo(GeschichteStatus.PUBLISHED); + } + + @Test + void getById_author_displayName_uses_firstName_lastName() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + published.setAuthor(AppUser.builder() + .id(UUID.randomUUID()).email("author@test") + .firstName("Hans").lastName("Raddatz").build()); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(published)); + + GeschichteView result = geschichteService.getById(id); + + assertThat(result.author().displayName()).isEqualTo("Hans Raddatz"); + } + + @Test + void getById_author_displayName_falls_back_to_email_when_names_blank() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + published.setAuthor(AppUser.builder() + .id(UUID.randomUUID()).email("anon@test").build()); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(published)); + + GeschichteView result = geschichteService.getById(id); + + assertThat(result.author().displayName()).isEqualTo("anon@test"); + } + + @Test + void getById_author_email_is_not_in_author_view() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + published.setAuthor(AppUser.builder() + .id(UUID.randomUUID()).email("secret@test") + .firstName("Max").lastName("M").build()); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(published)); + + GeschichteView result = geschichteService.getById(id); + + // AuthorView exposes only id + displayName — no email field at all + assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class); + assertThat(result.author().displayName()).doesNotContain("secret@test"); + } + + @Test + void getById_items_come_from_journeyItemService() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(published)); + when(journeyItemService.getItems(id)).thenReturn(List.of()); + + GeschichteView result = geschichteService.getById(id); + + assertThat(result.items()).isEmpty(); + verify(journeyItemService).getItems(id); } @Test