feat(geschichte): implement JourneyItemService — append, updateNote, delete, reorder (35 unit tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-08 16:47:42 +02:00
parent 2ad5c36e3c
commit fdc9273c86
2 changed files with 871 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.openapitools.jackson.nullable.JsonNullable;
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.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.geschichte.DocumentSummary;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class JourneyItemService {
static final int MAX_ITEMS = 100;
static final int POSITION_STEP = 10;
static final int MAX_NOTE_LENGTH = 5000;
private final JourneyItemRepository journeyItemRepository;
private final GeschichteRepository geschichteRepository;
private final DocumentService documentService;
private final AuditService auditService;
private final UserService userService;
@Transactional
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
Geschichte g = geschichteRepository.findById(geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Journey not found: " + geschichteId));
if (g.getType() != GeschichteType.JOURNEY) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Geschichte is not a JOURNEY — cannot append items");
}
long count = journeyItemRepository.countByGeschichteId(geschichteId);
if (count >= MAX_ITEMS) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Journey already has the maximum of " + MAX_ITEMS + " items");
}
String note = normalizeNote(dto.getNote());
if (dto.getDocumentId() == null && note == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"At least one of documentId or note must be provided");
}
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
Document doc = null;
if (dto.getDocumentId() != null) {
doc = documentService.getSummaryById(dto.getDocumentId());
}
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
.map(max -> max + POSITION_STEP)
.orElse(POSITION_STEP);
JourneyItem item = JourneyItem.builder()
.geschichte(g)
.position(nextPosition)
.document(doc)
.note(note)
.build();
JourneyItem saved = journeyItemRepository.save(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
return toView(saved);
}
@Transactional
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
JsonNullable<String> noteField = dto.getNote();
if (!noteField.isPresent()) {
return toView(item);
}
String note = normalizeNote(noteField.get());
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
if (note == null && item.getDocumentId() == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Cannot clear note on an item that has no linked document");
}
item.setNote(note);
JourneyItem saved = journeyItemRepository.save(item);
return toView(saved);
}
@Transactional
public void delete(UUID geschichteId, UUID itemId) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
journeyItemRepository.delete(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", itemId));
}
@Transactional
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
if (!existingIds.equals(new HashSet<>(requestedIds))) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Requested item IDs do not match the journey's existing items");
}
if (requestedIds.isEmpty()) {
return List.of();
}
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
Map<UUID, JourneyItem> itemMap = new HashMap<>();
for (JourneyItem item : items) {
itemMap.put(item.getId(), item);
}
List<JourneyItem> reordered = new ArrayList<>(requestedIds.size());
for (int i = 0; i < requestedIds.size(); i++) {
JourneyItem item = itemMap.get(requestedIds.get(i));
item.setPosition((i + 1) * POSITION_STEP);
reordered.add(journeyItemRepository.save(item));
}
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
return reordered.stream().map(this::toView).toList();
}
public List<JourneyItemView> getItems(UUID geschichteId) {
return journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)
.stream().map(this::toView).toList();
}
public DocumentSummary toSummary(Document doc) {
String senderName = buildSenderName(doc);
Set<Person> receivers = doc.getReceivers();
String receiverName = buildCanonicalReceiverName(receivers);
return new DocumentSummary(
doc.getId(),
doc.getTitle(),
doc.getDocumentDate(),
doc.getMetaDateEnd(),
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
senderName,
receiverName,
receivers != null ? receivers.size() : 0
);
}
public JourneyItemView toView(JourneyItem item) {
DocumentSummary docSummary = null;
if (item.getDocumentId() != null) {
Document doc = documentService.getSummaryById(item.getDocumentId());
docSummary = toSummary(doc);
}
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
}
private static String buildSenderName(Document doc) {
Person sender = doc.getSender();
if (sender != null) {
String name = join(sender.getFirstName(), sender.getLastName());
if (!name.isBlank()) return name;
}
String senderText = doc.getSenderText();
return (senderText != null && !senderText.isBlank()) ? senderText : null;
}
private static String buildCanonicalReceiverName(Set<Person> receivers) {
if (receivers == null || receivers.isEmpty()) return null;
return receivers.stream()
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
.map(p -> {
String name = join(p.getFirstName(), p.getLastName());
return name.isBlank() ? null : name;
})
.orElse(null);
}
private static String normalizeNote(String raw) {
if (raw == null || raw.isBlank()) return null;
return raw.trim();
}
private static String join(String first, String last) {
return ((first != null ? first : "") + " " + (last != null ? last : "")).trim();
}
private static String sortKey(String s) {
return s != null ? s : "";
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
return userService.findByEmail(auth.getName());
}
}

View File

@@ -0,0 +1,629 @@
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.openapitools.jackson.nullable.JsonNullable;
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.GeschichteRepository;
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 java.time.LocalDate;
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.argThat;
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 GeschichteRepository geschichteRepository;
@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);
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(geschichteRepository.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.save(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(geschichteRepository.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.save(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(geschichteRepository.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(geschichteRepository.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_returns400_when_note_exceeds_5000_chars() {
Geschichte journey = journey(geschichteId);
when(geschichteRepository.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(5001));
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void append_returns400_on_non_JOURNEY_type() {
Geschichte story = Geschichte.builder()
.id(geschichteId)
.title("Story")
.type(GeschichteType.STORY)
.status(GeschichteStatus.DRAFT)
.build();
when(geschichteRepository.findById(geschichteId)).thenReturn(Optional.of(story));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void append_returns404_when_documentId_does_not_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteRepository.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(documentService.getSummaryById(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_returns400_when_100_items_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteRepository.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.VALIDATION_ERROR));
}
@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(geschichteRepository.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.save(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
}
@Test
void append_audits_JOURNEY_ITEM_ADDED() {
Geschichte journey = journey(geschichteId);
when(geschichteRepository.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.save(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 JsonNullable.undefined() by default
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));
when(documentService.getSummaryById(docId)).thenReturn(doc);
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(JsonNullable.of(null));
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(JsonNullable.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(JsonNullable.of(null));
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));
when(documentService.getSummaryById(docId)).thenReturn(doc);
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(JsonNullable.of("\n \n"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void patch_returns400_when_note_exceeds_5000_chars() {
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(JsonNullable.of("x".repeat(5001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void patch_returns404_when_item_belongs_to_different_journey() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(JsonNullable.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_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.save(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.save(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.save(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.save(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 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;
}
}