feat(geschichte): getById returns GeschichteView; author email never exposed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ public class GeschichteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Geschichte getById(@PathVariable UUID id) {
|
public GeschichteView getById(@PathVariable UUID id) {
|
||||||
return geschichteService.getById(id);
|
return geschichteService.getById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ package org.raddatz.familienarchiv.geschichte;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.hibernate.Hibernate;
|
|
||||||
import org.owasp.html.HtmlPolicyBuilder;
|
import org.owasp.html.HtmlPolicyBuilder;
|
||||||
import org.owasp.html.PolicyFactory;
|
import org.owasp.html.PolicyFactory;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
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.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
@@ -33,10 +34,9 @@ public class GeschichteService {
|
|||||||
|
|
||||||
private final GeschichteRepository geschichteRepository;
|
private final GeschichteRepository geschichteRepository;
|
||||||
private final PersonService personService;
|
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 DocumentService documentService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final JourneyItemService journeyItemService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||||
@@ -57,7 +57,7 @@ public class GeschichteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Geschichte getById(UUID id) {
|
public GeschichteView getById(UUID id) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||||
@@ -66,11 +66,28 @@ public class GeschichteService {
|
|||||||
throw DomainException.notFound(
|
throw DomainException.notFound(
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
|
||||||
}
|
}
|
||||||
// Force-initialize LAZY items inside this transaction.
|
// Items loaded via repository query — never through the LAZY collection on Geschichte.
|
||||||
// open-in-view is FALSE — without this touch, Jackson serializes a closed
|
// This keeps open-in-view:false safe without Hibernate.initialize.
|
||||||
// Hibernate session and throws LazyInitializationException → HTTP 500.
|
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||||
Hibernate.initialize(g.getItems());
|
return toView(g, items);
|
||||||
return g;
|
}
|
||||||
|
|
||||||
|
private GeschichteView toView(Geschichte g, List<JourneyItemView> 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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class GeschichteControllerTest {
|
|||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getById_returns200_whenFound() throws Exception {
|
void getById_returns200_whenFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
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))
|
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -233,6 +233,13 @@ class GeschichteControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private GeschichteView viewStub(UUID id, String title) {
|
||||||
|
return new GeschichteView(id, title, "<p>x</p>",
|
||||||
|
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. */
|
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||||
private GeschichteSummary summaryStub(String title) {
|
private GeschichteSummary summaryStub(String title) {
|
||||||
return new GeschichteSummary() {
|
return new GeschichteSummary() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteView;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||||
@@ -104,9 +105,9 @@ class GeschichteServiceIntegrationTest {
|
|||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1);
|
assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1);
|
||||||
assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1);
|
assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1);
|
||||||
Geschichte fetched = geschichteService.getById(draftId);
|
GeschichteView fetched = geschichteService.getById(draftId);
|
||||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
assertThat(fetched.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
assertThat(fetched.persons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||||
|
|
||||||
// Delete as writer; join rows go with it
|
// Delete as writer; join rows go with it
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
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.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -40,17 +42,13 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class GeschichteServiceTest {
|
class GeschichteServiceTest {
|
||||||
|
|
||||||
@Mock
|
@Mock GeschichteRepository geschichteRepository;
|
||||||
GeschichteRepository geschichteRepository;
|
@Mock PersonService personService;
|
||||||
@Mock
|
@Mock DocumentService documentService;
|
||||||
PersonService personService;
|
@Mock UserService userService;
|
||||||
@Mock
|
@Mock JourneyItemService journeyItemService;
|
||||||
DocumentService documentService;
|
|
||||||
@Mock
|
|
||||||
UserService userService;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks GeschichteService geschichteService;
|
||||||
GeschichteService geschichteService;
|
|
||||||
|
|
||||||
AppUser writer;
|
AppUser writer;
|
||||||
AppUser reader;
|
AppUser reader;
|
||||||
@@ -60,6 +58,7 @@ class GeschichteServiceTest {
|
|||||||
SecurityContextHolder.clearContext();
|
SecurityContextHolder.clearContext();
|
||||||
writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build();
|
writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build();
|
||||||
reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build();
|
reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build();
|
||||||
|
lenient().when(journeyItemService.getItems(any())).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
@@ -89,9 +88,10 @@ class GeschichteServiceTest {
|
|||||||
Geschichte draft = draft(id);
|
Geschichte draft = draft(id);
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));
|
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
|
@Test
|
||||||
@@ -101,9 +101,70 @@ class GeschichteServiceTest {
|
|||||||
Geschichte published = published(id);
|
Geschichte published = published(id);
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user