From fdc9273c860f58476a3084dc9903b01ac07740b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 16:47:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(geschichte):=20implement=20JourneyItemServ?= =?UTF-8?q?ice=20=E2=80=94=20append,=20updateNote,=20delete,=20reorder=20(?= =?UTF-8?q?35=20unit=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../journeyitem/JourneyItemService.java | 242 +++++++ .../journeyitem/JourneyItemServiceTest.java | 629 ++++++++++++++++++ 2 files changed, 871 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java new file mode 100644 index 00000000..6b1923f8 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -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 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 reorder(UUID geschichteId, JourneyReorderDTO dto) { + Set existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId); + List 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 items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId); + Map itemMap = new HashMap<>(); + for (JourneyItem item : items) { + itemMap.put(item.getId(), item); + } + + List 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 getItems(UUID geschichteId) { + return journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId) + .stream().map(this::toView).toList(); + } + + public DocumentSummary toSummary(Document doc) { + String senderName = buildSenderName(doc); + Set 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 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()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java new file mode 100644 index 00000000..9ffb4fc1 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java @@ -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 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 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 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 ids = new java.util.ArrayList<>(); + List 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 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 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; + } +}