feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s

This commit was merged in pull request #787.
This commit is contained in:
2026-06-12 14:04:02 +02:00
parent 4bcf568ed4
commit b33d0eb850
142 changed files with 11643 additions and 917 deletions

View File

@@ -402,6 +402,7 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL")
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id).with(csrf()))
.andExpect(status().isNoContent());

View File

@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
.doesNotThrowAnyException();
}
@Test
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
// q + default sort + no other filters → the relevance fast path
// (relevanceSortedPageFromSql), which loads documents by id outside any
// transaction and must still deliver an initialized tags collection.
Person sender = savedPerson("Hans", "FtSender");
Tag tag = savedTag("FtTag");
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
SearchFilters textOnly = new SearchFilters(
"Walter", null, null, null, null, null, null, null, null, false);
DocumentSearchResult result = documentService.searchDocuments(
textOnly, null, "DESC", PageRequest.of(0, 10));
assertThat(result.totalElements()).isEqualTo(1);
assertThatCode(() ->
result.items().forEach(i -> i.tags().size()))
.doesNotThrowAnyException();
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
}
@Test
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SsSender");

View File

@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID();
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any()))
when(documentRepository.findByIdIn(any()))
.thenReturn(List.of(doc(id1)));
documentService.searchDocuments(
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),

View File

@@ -30,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.tag.TagService;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@@ -75,6 +76,7 @@ class DocumentServiceTest {
@Mock AuditLogQueryService auditLogQueryService;
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
@Mock ApplicationEventPublisher eventPublisher;
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
// shared composition rather than a stub — the #726 single source of truth.
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
@@ -87,7 +89,7 @@ class DocumentServiceTest {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(true);
documentService.deleteDocument(id);
documentService.deleteDocument(id, UUID.randomUUID());
verify(documentRepository).deleteById(id);
}
@@ -97,7 +99,7 @@ class DocumentServiceTest {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(false);
assertThatThrownBy(() -> documentService.deleteDocument(id))
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
verify(documentRepository, never()).deleteById(any());
@@ -2166,7 +2168,7 @@ class DocumentServiceTest {
List<Object[]> ftsRows = new java.util.ArrayList<>();
ftsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -2202,7 +2204,7 @@ class DocumentServiceTest {
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(

View File

@@ -0,0 +1,66 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
* class level (see JourneyItemConstraintsTest for the rationale).
*
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class GeschichteConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
private UUID insertGeschichte(String type, String body) {
UUID id = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
id, "Constraints-Test", body, type);
return id;
}
@Test
void journey_intro_check_rejects_4001_chars() {
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void journey_intro_check_accepts_exactly_4000_chars() {
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
@Test
void story_bodies_are_not_constrained_by_the_intro_check() {
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
}

View File

@@ -2,15 +2,13 @@ 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.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.security.SecurityConfig;
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,22 +19,25 @@ 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;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
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.request.MockMvcRequestBuilders.put;
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})
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean
GeschichteService geschichteService;
@MockitoBean
CustomUserDetailsService customUserDetailsService;
@MockitoBean GeschichteService geschichteService;
@MockitoBean JourneyItemService journeyItemService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/geschichten ────────────────────────────────────────────────
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
@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")));
.thenReturn(List.of(summaryStub("Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
@@ -101,13 +100,50 @@ class GeschichteControllerTest {
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesDocumentIdFilterToService() throws Exception {
UUID documentId = UUID.randomUUID();
when(geschichteService.list(any(), any(), eq(documentId), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("documentId", documentId.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), any(), eq(documentId), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesLimitToService() throws Exception {
when(geschichteService.list(any(), any(), any(), eq(5)))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("limit", "5"))
.andExpect(status().isOk());
verify(geschichteService).list(any(), any(), any(), eq(5));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesStatusFilterToService() throws Exception {
when(geschichteService.list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("status", "PUBLISHED"))
.andExpect(status().isOk());
verify(geschichteService).list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void getById_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isOk())
@@ -119,7 +155,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id))
when(geschichteService.getView(id))
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
mockMvc.perform(get("/api/geschichten/{id}", id))
@@ -151,7 +187,7 @@ class GeschichteControllerTest {
void create_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
.thenReturn(draft(id, "New"));
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New");
@@ -179,7 +215,7 @@ class GeschichteControllerTest {
void update_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated"));
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@@ -208,31 +244,202 @@ class GeschichteControllerTest {
verify(geschichteService).delete(id);
}
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
@Test
void appendItem_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Note\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(itemId.toString()))
.andExpect(jsonPath("$.position").value(10));
}
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Updated\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value("Updated"));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, null));
// Raw JSON — local objectMapper lacks JsonNullableModule
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\": null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value(nullValue()));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNoContent());
verify(journeyItemService).delete(id, itemId);
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
.when(journeyItemService).delete(id, itemId);
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void reorderItems_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
}
// ─── error mapping ───────────────────────────────────────────────────────
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns409_on_position_conflict() throws Exception {
UUID id = UUID.randomUUID();
when(journeyItemService.append(eq(id), any()))
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.body("<p>x</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
private JourneyItemView itemViewStub(UUID id, int position, String note) {
return new JourneyItemView(id, position, null, note);
}
private Geschichte draft(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.status(GeschichteStatus.DRAFT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
private GeschichteView viewStub(UUID id, String title) {
return viewStub(id, title, GeschichteStatus.PUBLISHED);
}
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
return new GeschichteView(id, title, "<p>x</p>",
status, 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() {
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 LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
public String getBody() { return null; }
};
}
}

View File

@@ -0,0 +1,298 @@
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.raddatz.familienarchiv.user.UserGroup;
import org.raddatz.familienarchiv.user.UserGroupRepository;
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.http.client.JdkClientHttpRequestFactory;
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.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
*
* <p>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 UserGroupRepository userGroupRepository;
@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.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::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<String> 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<String> 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<String> 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<String> 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<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
}
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
@Test
void update_returns_200_and_serializes_items_open_in_view_false() {
// Canonical guard for the write path: PATCH must not 500 when the response
// is serialized after the service transaction closed. The raw entity carries
// a dead lazy items proxy at that point — the endpoint must answer with a
// view assembled inside the transaction.
AppUser writer = blogWriter();
Geschichte journey = Geschichte.builder()
.title("Reise vor dem Umbenennen")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
journey.getItems().add(JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build());
Geschichte saved = geschichteRepository.save(journey);
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Reise nach dem Umbenennen")
.contains("Prolog");
}
// ─── helpers ─────────────────────────────────────────────────────────────
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
private AppUser blogWriter() {
UserGroup group = userGroupRepository.save(UserGroup.builder()
.name("HttpTest-BlogWriters")
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
.build());
return appUserRepository.save(AppUser.builder()
.email(BLOG_WRITER_EMAIL)
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
.groups(new HashSet<>(Set.of(group)))
.build());
}
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
private HttpHeaders csrfJsonHeaders(String sessionId) {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private String loginAsWriter() {
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
}
private String loginAs(String email, String password) {
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\":\"" + email + "\",\"password\":\"" + password + "\"}";
ResponseEntity<String> 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<String> 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() {
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

@@ -0,0 +1,262 @@
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.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
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;
@Autowired DocumentRepository documentRepository;
@Autowired JourneyItemRepository journeyItemRepository;
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<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
}
@Test
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
// projection must carry it for drafts, where publishedAt is null.
geschichteRepository.save(draft("Mein Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUpdatedAt()).isNotNull();
}
@Test
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
geschichteRepository.save(draft("Nur Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).isEmpty();
}
// ─── AuthorSummary nested projection ─────────────────────────────────────
@Test
void findSummaries_exposes_nested_author_names_but_never_email() {
AppUser richAuthor = appUserRepository.save(AppUser.builder()
.firstName("Franz").lastName("Raddatz")
.email("franz@raddatz.de").password("pw").build());
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
assertThat(a.getFirstName()).isEqualTo("Franz");
assertThat(a.getLastName()).isEqualTo("Raddatz");
// Design rule (GeschichteView.AuthorView javadoc): author projections never
// expose email or group memberships to readers.
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
.extracting(java.lang.reflect.Method::getName)
.doesNotContain("getEmail");
}
// ─── 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<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
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<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
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<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
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<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
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<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Both");
}
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
@Test
void findSummaries_with_documentId_returns_journey_containing_that_document() {
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
journeyItemRepository.save(JourneyItem.builder()
.geschichte(withDoc).document(doc).position(1).build());
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
}
@Test
void findSummaries_with_unknown_documentId_returns_empty() {
geschichteRepository.save(journey("Irgendeine Reise", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
assertThat(result).isEmpty();
}
// ─── 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();
}
private Geschichte journey(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.build();
}
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
private List<UUID> sentinel() {
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
}
}

View File

@@ -0,0 +1,38 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteQueryServiceTest {
@Mock
GeschichteRepository geschichteRepository;
@InjectMocks
GeschichteQueryService geschichteQueryService;
@Test
void existsById_returns_true_when_geschichte_exists() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(true);
assertThat(geschichteQueryService.existsById(id)).isTrue();
}
@Test
void existsById_returns_false_when_geschichte_does_not_exist() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(false);
assertThat(geschichteQueryService.existsById(id)).isFalse();
}
}

View File

@@ -8,9 +8,12 @@ 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.GeschichteType;
import org.raddatz.familienarchiv.geschichte.GeschichteView;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.beans.factory.annotation.Autowired;
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
S3Client s3Client;
@Autowired GeschichteService geschichteService;
@Autowired JourneyItemService journeyItemService;
@Autowired GeschichteRepository geschichteRepository;
@Autowired PersonRepository personRepository;
@Autowired AppUserRepository appUserRepository;
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
+ "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId()));
Geschichte created = geschichteService.create(dto);
GeschichteView created = geschichteService.create(dto);
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody())
assertThat(created.id()).isNotNull();
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.body())
.contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>");
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
UUID draftId = created.id();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found");
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull();
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.publishedAt()).isNotNull();
// 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);
Geschichte fetched = geschichteService.getById(draftId);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
// Delete as writer; join rows go with it
authenticateAs(writer, Permission.BLOG_WRITE);
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(Geschichte::getId)
.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)
.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)
.extracting(GeschichteSummary::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE);
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
assertThat(result).isEmpty();
}
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
dto.setBody("<p>body</p>");
dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).getId();
return geschichteService.create(dto).id();
}
private void authenticateAs(AppUser user, Permission... permissions) {

View File

@@ -7,26 +7,22 @@ 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.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
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 +33,10 @@ 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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -45,17 +44,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;
@@ -96,7 +91,8 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(draft);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
}
@Test
@@ -108,7 +104,8 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(published);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
}
@Test
@@ -123,79 +120,190 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
// ─── getView ──────────────────────────────────────────────────────────────
@Test
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
Geschichte published = published(id);
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
GeschichteView view = geschichteService.getView(id);
assertThat(view.id()).isEqualTo(id);
assertThat(view.items()).containsExactly(item);
verify(journeyItemService).getItems(id);
}
@Test
void getView_throws_NOT_FOUND_when_id_unknown() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> geschichteService.getView(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
@Test
void toView_author_displayName_uses_firstName_lastName() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("author@test")
.firstName("Hans").lastName("Raddatz").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
}
@Test
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("anon@test").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
}
@Test
void toView_author_email_is_not_in_author_view() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("secret@test")
.firstName("Max").lastName("M").build());
GeschichteView result = geschichteService.toView(published, List.of());
// 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 toView_persons_are_mapped_to_PersonView() {
UUID id = UUID.randomUUID();
UUID personId = UUID.randomUUID();
Geschichte published = published(id);
published.setPersons(new HashSet<>(List.of(
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
)));
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.persons()).hasSize(1);
GeschichteView.PersonView pv = result.persons().iterator().next();
assertThat(pv.id()).isEqualTo(personId);
assertThat(pv.firstName()).isEqualTo("Franz");
assertThat(pv.lastName()).isEqualTo("Raddatz");
}
@Test
void toView_items_are_passed_through() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.items()).isEmpty();
}
// ─── list ─────────────────────────────────────────────────────────────────
@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(), any()))
.thenReturn(List.of());
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
geschichteService.list(null, List.of(), null, 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(), any());
}
@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(), any()))
.thenReturn(List.of(s1, s2));
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@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(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@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(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_filters_by_documentId() {
void list_passes_documentId_to_repository_as_journey_item_filter() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
}
@Test
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
// B2: when personIds is empty/null the service must pass a sentinel NIL UUID
// so the IN() predicate is skipped without producing invalid empty-IN() SQL.
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(), any()))
.thenReturn(List.of());
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
geschichteService.list(null, List.of(), null, 50);
UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
verify(geschichteRepository).findSummaries(
any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
}
@Test
void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(mock(GeschichteSummary.class)));
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@@ -213,11 +321,11 @@ class GeschichteServiceTest {
dto.setTitle("My Story");
dto.setBody("<p>plain text</p>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.getAuthor()).isSameAs(writer);
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
assertThat(saved.author().id()).isEqualTo(writer.getId());
}
@Test
@@ -231,9 +339,9 @@ class GeschichteServiceTest {
dto.setTitle("XSS attempt");
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody())
assertThat(saved.body())
.contains("<p>safe</p>")
.doesNotContain("<script>")
.doesNotContain("onerror")
@@ -252,9 +360,9 @@ class GeschichteServiceTest {
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody())
assertThat(saved.body())
.contains("<h2>Heading</h2>")
.contains("<strong>bold</strong>")
.contains("<em>italic</em>")
@@ -277,28 +385,9 @@ class GeschichteServiceTest {
dto.setTitle("Linked");
dto.setPersonIds(List.of(personId));
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
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);
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
}
@Test
@@ -315,6 +404,202 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
@Test
void create_preserves_JOURNEY_type_from_dto() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Journey");
dto.setType(GeschichteType.JOURNEY);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void create_defaults_to_STORY_when_type_is_null() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Story");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
}
@Test
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
// The journey intro is plain text: JourneyReader renders it via Svelte text
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
// would corrupt real content ("Müller & Söhne" → "Müller &amp; Söhne") and
// re-encode cumulatively on every editor round-trip.
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("Müller & Söhne, Temperatur < 0");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
}
@Test
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("Temperatur < 0 & Schnee");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
}
@Test
void update_still_sanitizes_STORY_body() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
// ─── length caps ─────────────────────────────────────────────────────────
@Test
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_accepts_title_of_exactly_255_chars() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(255));
assertThat(geschichteService.create(dto).title()).hasSize(255);
}
@Test
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4000));
assertThat(geschichteService.create(dto).body()).hasSize(4000);
}
@Test
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
}
// ─── update ──────────────────────────────────────────────────────────────
@Test
@@ -330,10 +615,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.getPublishedAt()).isNotNull();
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.publishedAt()).isNotNull();
}
@Test
@@ -349,10 +634,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.DRAFT);
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
}
@Test
@@ -366,9 +651,46 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.JOURNEY);
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
}
@Test
void update_accepts_dto_carrying_the_unchanged_type() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.STORY);
dto.setTitle("Unverändert getypt");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
assertThat(saved.title()).isEqualTo("Unverändert getypt");
}
@Test
@@ -426,7 +748,7 @@ class GeschichteServiceTest {
.body("<p>body</p>")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
@@ -438,7 +760,7 @@ class GeschichteServiceTest {
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now().minusHours(1))
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
}

View File

@@ -0,0 +1,165 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
* rollback-only and cascades into TransactionSystemException on teardown.
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository documentRepository;
private UUID geschichteId;
private UUID documentId;
@BeforeEach
void seed() {
jdbcTemplate.execute("DELETE FROM journey_items");
Document doc = documentRepository.save(Document.builder()
.title("Constraints-Test-Doc")
.originalFilename("ct.pdf")
.status(DocumentStatus.UPLOADED)
.build());
documentId = doc.getId();
Geschichte g = geschichteRepository.save(Geschichte.builder()
.title("Constraints-Test-Journey")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
geschichteId = g.getId();
}
@Test
void unique_constraint_is_deferrable_initially_deferred() {
Boolean condeferrable = jdbcTemplate.queryForObject(
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
Boolean condeferred = jdbcTemplate.queryForObject(
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
}
@Test
void unique_index_rejects_duplicate_document_per_geschichte() {
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_index_allows_same_document_in_different_journeys() {
Geschichte other = geschichteRepository.save(Geschichte.builder()
.title("Andere Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), other.getId(), 10, documentId);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
assertThat(count).isEqualTo(2);
}
@Test
void unique_index_allows_multiple_note_only_items() {
// document_id IS NULL rows must not collide — the index is partial.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(2);
}
@Test
void note_length_check_rejects_2001_chars() {
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void note_length_check_accepts_exactly_2000_chars() {
// Pins the boundary at the DB layer too — a future <= vs < migration edit
// must fail here, not only in the mock-based service test.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(1);
}
@Test
void position_check_rejects_nonpositive() {
UUID itemId = UUID.randomUUID();
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
itemId, geschichteId, 0, "test"))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_constraint_rejects_duplicate_position_per_geschichte() {
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
}

View File

@@ -0,0 +1,261 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
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.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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
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;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemDocumentDeleteTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditService auditService;
@MockitoSpyBean
DocumentRepository documentRepository;
@PersistenceContext
EntityManager em;
@Autowired DocumentService documentService;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository docRepo;
@Autowired AppUserRepository appUserRepository;
@Autowired JdbcTemplate jdbcTemplate;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("delete-test-writer@test")
.password("hash")
.build());
doc = docRepo.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());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
@AfterEach
void cleanup() {
SecurityContextHolder.clearContext();
reset(documentRepository);
// Deletion order is FK-load-bearing: journey_items reference both documents
// and geschichten, so children must be removed before their parents.
journeyItemRepository.deleteAll();
docRepo.deleteAll();
geschichteRepository.deleteAll();
appUserRepository.deleteAll();
}
// ─── AC-1: headline ───────────────────────────────────────────────────────
@Test
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
assertThat(docRepo.findById(doc.getId())).isEmpty();
}
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
@Test
void deleting_document_preserves_note_carrying_item_as_placeholder() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("curator context");
}
// ─── AC-3: note-only item untouched ───────────────────────────────────────
@Test
void deleting_document_does_not_affect_note_only_item() {
JourneyItem noteOnly = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
assertThat(reloaded.getDocumentId()).isNull();
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
}
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
@Test
void deleting_document_applies_independently_per_referencing_item() {
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
JourneyItem noteLess = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
JourneyItem noteCarrying = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("Begleittext");
}
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
@Test
void listener_deletes_roll_back_when_document_delete_fails() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
doThrow(new RuntimeException("simulated failure"))
.when(documentRepository).deleteById(any());
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
.isInstanceOf(RuntimeException.class);
em.clear();
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
}
// ─── AC-6: empty-string note boundary ────────────────────────────────────
@Test
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
// uq_journey_items_geschichte_document prevents two items with the same
// (geschichte_id, document_id) in one journey — use two separate journeys.
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise für AC-6")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
UUID emptyNoteItemId = UUID.randomUUID();
UUID whitespaceNoteItemId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
assertThat(whitespaceItem.getDocumentId()).isNull();
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
}
// ─── Idempotency / no-collateral ──────────────────────────────────────────
@Test
void deleting_document_in_zero_journeys_returns_no_collateral() {
Document unlinked = docRepo.save(Document.builder()
.title("Unverknüpfter Brief")
.originalFilename("other.pdf")
.status(DocumentStatus.UPLOADED)
.build());
JourneyItem unrelated = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
em.clear();
documentService.deleteDocument(unlinked.getId(), writer.getId());
em.clear();
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
assertThat(journeyItemRepository.count()).isEqualTo(1);
}
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
@Test
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
}
}

View File

@@ -0,0 +1,418 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
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.raddatz.familienarchiv.security.Permission;
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.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
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;
@MockitoBean
AuditService auditService;
@PersistenceContext
EntityManager em;
@Autowired GeschichteRepository geschichteRepository;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired JourneyItemService journeyItemService;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@Autowired AppUserRepository appUserRepository;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("journey-writer@test")
.password("hash")
.build());
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();
}
@AfterEach
void clearSecurity() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))
.toList();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
// ─── @OrderBy ─────────────────────────────────────────────────────────────
@Test
void items_are_returned_in_position_order_regardless_of_insertion_order() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
// Distinct content per item — V74's partial unique index forbids the same
// document twice in one journey, and ordering doesn't depend on it.
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
managed.getItems().addAll(List.of(third, first, second));
geschichteRepository.save(managed);
em.flush();
em.clear();
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
List<Integer> 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.findByGeschichteIdOrderByPosition(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();
// Route through service so the DocumentDeletingEvent fires and the listener
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
documentService.deleteDocument(doc.getId(), writer.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);
}
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
@Test
void append_persists_item_at_position_10() {
// Arrange: authenticate as a user with BLOG_WRITE
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("First stop");
// Act
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
// Assert: item exists in DB at position 10
assertThat(view.position()).isEqualTo(10);
assertThat(view.note()).isEqualTo("First stop");
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(1);
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
}
@Test
void append_document_persists_and_rejects_duplicate() {
// Covers the document branch of append, including the duplicate guard —
// the derived exists query must resolve document.id, which the transient
// getDocumentId() getter on JourneyItem shadows for Spring Data.
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
assertThat(view.document()).isNotNull();
assertThat(view.document().id()).isEqualTo(doc.getId());
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
duplicate.setDocumentId(doc.getId());
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
.hasFieldOrPropertyWithValue("code",
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
}
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
@Test
void story_type_can_hold_journey_items_through_service() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).id()).isEqualTo(appended.id());
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
}
@Test
void v72_migrated_story_items_keep_position_order_and_are_removable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
Document docB = documentRepository.save(Document.builder()
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
Document docC = documentRepository.save(Document.builder()
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
// V72 inserted journey_items rows directly with position gaps — mirror that
// by writing through the repository instead of the service.
JourneyItem first = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
JourneyItem second = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
JourneyItem third = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::position)
.containsExactly(10, 20, 30);
journeyItemService.delete(story.getId(), second.getId());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::id)
.containsExactly(first.getId(), third.getId());
}
@Test
void story_item_with_deleted_document_survives_and_remains_deletable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
// SET NULL clears document_id — a note-less item would block the
// document delete at the DB instead.
dto.setNote("Begleittext");
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
documentService.deleteDocument(doc.getId(), writer.getId());
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).document()).isNull();
journeyItemService.delete(story.getId(), appended.id());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
}
private Geschichte savedStory() {
return geschichteRepository.save(Geschichte.builder()
.title("Eine Geschichte")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.STORY)
.build());
}
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
@Test
void reorder_swaps_positions_atomically() {
// Arrange: append two items (pos 10, pos 20)
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
dto1.setNote("Item one");
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
dto2.setNote("Item two");
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
assertThat(item1View.position()).isEqualTo(10);
assertThat(item2View.position()).isEqualTo(20);
// Act: reorder with [item2, item1]
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
em.flush();
em.clear();
// Assert: item2 is now at position 10, item1 is at position 20
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(2);
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
}
}

View File

@@ -0,0 +1,822 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
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.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class JourneyItemServiceTest {
@Mock JourneyItemRepository journeyItemRepository;
@Mock GeschichteQueryService geschichteQueryService;
@Mock DocumentService documentService;
@Mock AuditService auditService;
@Mock UserService userService;
@InjectMocks JourneyItemService journeyItemService;
UUID geschichteId = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID actorId = UUID.randomUUID();
@BeforeEach
void setupAuth() {
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("test@test.de", null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
// ─── toSummary — name composition ────────────────────────────────────────
@Test
void toSummary_uses_linked_person_firstName_lastName() {
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
Document doc = makeDoc(docId, sender, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
}
@Test
void toSummary_falls_back_to_senderText_when_no_person() {
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Familie Müller");
}
@Test
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isNull();
}
@Test
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(0);
assertThat(summary.receiverName()).isNull();
}
@Test
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(2);
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
}
@Test
void toSummary_datePrecision_SEASON_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.SEASON);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
}
@Test
void toSummary_datePrecision_APPROX_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.APPROX);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
}
// ─── append ──────────────────────────────────────────────────────────────
@Test
void append_to_empty_journey_starts_at_10() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
}
@Test
void append_after_reorder_continues_from_max_position() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(50);
}
@Test
void append_returns400_when_neither_documentId_nor_note() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("documentId or note");
}
@Test
void append_returns400_when_note_trims_to_empty_and_no_document() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote(" \n ");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2001));
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void append_accepts_note_of_exactly_2000_chars() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2000));
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
}
@Test
void append_returns404_when_documentId_does_not_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(documentService.findSummaryByIdInternal(docId))
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
}
@Test
void append_returns409_when_100_items_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void append_returns409_when_document_already_in_journey() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_to_STORY_type_creates_journey_item() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
Document doc = makeDoc(docId, null, List.of(), null, null);
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
assertThat(view.document().id()).isEqualTo(docId);
}
@Test
void append_to_STORY_type_respects_capacity_cap() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void append_to_STORY_type_rejects_duplicate_document() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void cap_is_COUNT_based_not_MAX_position_based() {
// 99 rows with MAX(position)=2000 should still accept the 100th append
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
}
@Test
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// Two concurrent appends can both pass the exists() pre-check; the partial
// unique index then rejects the second INSERT at flush. The service must
// translate that into the same friendly 409 as the pre-check.
// Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
// constraint-name string matching — constraint renames must not regress this.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
// Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
// wrapped by Spring's DataIntegrityViolationException.
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
org.springframework.dao.DataIntegrityViolationException dive =
new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
// An FK violation (document deleted between load and flush) must NOT be
// translated into "already added" — only the dedup unique index (23505) earns that 409.
// FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
}
@Test
void append_audits_JOURNEY_ITEM_ADDED() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
journeyItemService.append(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
}
// ─── updateNote ───────────────────────────────────────────────────────────
@Test
void updateNote_absent_leaves_note_unchanged() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
// note is null by default — absent from JSON, no-op
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("Original note");
verify(journeyItemRepository, never()).save(any());
}
@Test
void updateNote_null_clears_note_when_document_is_present() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void updateNote_string_sets_note() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
item.setNote(null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("New note");
}
@Test
void updateNote_null_returns400_when_item_has_no_document() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void updateNote_whitespace_only_including_newlines_stored_as_null() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("\n \n"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("x".repeat(2001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void updateNote_auditsNoteUpdate() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
journeyItemService.updateNote(geschichteId, itemId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
}
@Test
void patch_returns404_when_item_belongs_to_different_journey() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("text"));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
// ─── delete ───────────────────────────────────────────────────────────────
@Test
void delete_returns404_when_item_already_deleted() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
@Test
void delete_no_audit_when_item_not_found() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
@Test
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
journeyItemService.delete(geschichteId, itemId);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
}
// ─── reorder ─────────────────────────────────────────────────────────────
@Test
void reorder_unknownGeschichteId_throws404() {
UUID unknownId = UUID.randomUUID();
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
}
@Test
void reorder_returns400_when_itemIds_contain_duplicates() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id1)); // duplicate
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_itemId_belongs_to_different_journey() {
UUID foreignId = UUID.randomUUID();
UUID localId = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(foreignId));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_ids_have_extra_items() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns200_when_empty_on_empty_journey() {
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
assertThat(result).isEmpty();
}
@Test
void reorder_returns400_when_empty_on_nonempty_journey() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns_items_in_new_order_starting_at_10() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2)); // want id1 first
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(2);
assertThat(views.get(0).id()).isEqualTo(id1);
assertThat(views.get(0).position()).isEqualTo(10);
assertThat(views.get(1).id()).isEqualTo(id2);
assertThat(views.get(1).position()).isEqualTo(20);
}
@Test
void reorder_identical_order_returns200() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(1);
assertThat(views.get(0).position()).isEqualTo(10);
}
@Test
void reorder_of_grandfathered_over_cap_journey_succeeds() {
Geschichte journey = journey(geschichteId);
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
List<UUID> ids = new java.util.ArrayList<>();
List<JourneyItem> items = new java.util.ArrayList<>();
for (int i = 1; i <= 130; i++) {
UUID id = UUID.randomUUID();
ids.add(id);
items.add(savedItem(id, journey, i * 10, null, "item " + i));
}
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(ids);
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(130);
}
@Test
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
journeyItemService.reorder(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte journey(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Journey")
.type(GeschichteType.JOURNEY)
.status(GeschichteStatus.DRAFT)
.build();
}
private Geschichte story(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Story")
.type(GeschichteType.STORY)
.status(GeschichteStatus.DRAFT)
.build();
}
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
return JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(null) // no document entity to avoid LAZY issues in unit tests
.note(note)
.build();
}
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
JourneyItem item = JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(doc)
.note(note)
.build();
return item;
}
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
Document doc = Document.builder()
.id(id)
.title("Test Doc")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.senderText(senderText)
.receiverText(receiverText)
.sender(sender)
.build();
doc.setReceivers(new HashSet<>(receivers));
return doc;
}
}