diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java new file mode 100644 index 00000000..d8b6cd76 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -0,0 +1,219 @@ +package org.raddatz.familienarchiv.timeline; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +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.person.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef; +import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView; + +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Curator CRUD for {@link TimelineEvent}. Persons and documents are resolved through their own + * services (never their repositories). All four body-returning operations return a + * {@link TimelineEventView} assembled in-transaction — the entity is never serialized (ADR-040 §2). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TimelineEventService { + + private static final int MAX_TITLE_LENGTH = 255; + + private final TimelineEventRepository events; + private final PersonService personService; + private final DocumentService documentService; + + @Transactional + public TimelineEventView create(TimelineEventRequest request, UUID actorId) { + validateRangeInvariant(request); + validateTitleLength(request); + DatePrecision precision = effectivePrecision(request); + + TimelineEvent event = TimelineEvent.builder() + .title(request.title()) + .type(request.type()) + .eventDate(normalizeEventDate(request.eventDate(), precision)) + .precision(precision) + .eventDateEnd(request.eventDateEnd()) + .description(request.description()) + .persons(resolvePersons(request.personIds())) + .documents(resolveDocuments(request.documentIds())) + .createdBy(actorId) + .updatedBy(actorId) + .build(); + + return toView(events.saveAndFlush(event)); + } + + @Transactional + public TimelineEventView update(UUID id, TimelineEventRequest request, UUID actorId) { + TimelineEvent event = events.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, + "Timeline event not found: " + id)); + validateRangeInvariant(request); + validateTitleLength(request); + applyUpdate(event, request, actorId); + + // saveAndFlush (not save) so the versioned UPDATE …WHERE version=? fires HERE, inside the + // try — a bare save() flushes at commit, after this method returns, so the exception would + // escape the catch and surface as a 500. Catch the Spring-translated type, not JPA's. + try { + return toView(events.saveAndFlush(event)); + } catch (ObjectOptimisticLockingFailureException ex) { + throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT, + "Timeline event was modified concurrently: " + id); + } + } + + @Transactional + public void delete(UUID id) { + TimelineEvent event = events.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, + "Timeline event not found: " + id)); + events.delete(event); + } + + /** + * View-assembly read. {@code @Transactional(readOnly = true)} is load-bearing, not optional: + * the LAZY {@code persons}/{@code documents} collections are traversed during {@link #toView} + * assembly, and under {@code open-in-view: false} a closed session there is a + * {@code LazyInitializationException} (ADR-022 / {@code getDocumentDetail} precedent). + */ + @Transactional(readOnly = true) + public TimelineEventView getEvent(UUID id) { + TimelineEvent event = events.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, + "Timeline event not found: " + id)); + return toView(event); + } + + // --- update mechanics: mutate the managed entity, never reassign collections --- + + private void applyUpdate(TimelineEvent event, TimelineEventRequest request, UUID actorId) { + DatePrecision precision = effectivePrecision(request); + event.setTitle(request.title()); + event.setType(request.type()); + event.setEventDate(normalizeEventDate(request.eventDate(), precision)); + event.setPrecision(precision); + event.setEventDateEnd(request.eventDateEnd()); + event.setDescription(request.description()); + replaceLinks(event, request); + event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes + if (request.version() != null) { + event.setVersion(request.version()); + } + } + + /** + * Replaces (set semantics) the link collections. Mutates the existing managed collections — + * Hibernate does not track a reassigned reference, and a fresh {@code Set} risks orphan join + * rows against the {@code ON DELETE CASCADE} join tables. A null or empty list clears all links. + */ + private void replaceLinks(TimelineEvent event, TimelineEventRequest request) { + event.getPersons().clear(); + event.getPersons().addAll(resolvePersons(request.personIds())); + event.getDocuments().clear(); + event.getDocuments().addAll(resolveDocuments(request.documentIds())); + } + + // --- validation / normalization --- + + /** Mirrors the DB biconditional CHECK chk_timeline_event_range — both directions. */ + private void validateRangeInvariant(TimelineEventRequest request) { + boolean isRange = effectivePrecision(request) == DatePrecision.RANGE; + if (request.eventDateEnd() != null && !isRange) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "eventDateEnd is only valid when precision is RANGE"); + } + if (isRange && request.eventDateEnd() == null) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "A RANGE event requires a non-null eventDateEnd"); + } + } + + /** + * Load-bearing only for non-HTTP callers: the DTO {@code @Size(max = 255)} already covers HTTP + * callers, but a non-HTTP caller could otherwise push an over-long title to the VARCHAR(255) + * column and get a raw {@code DataIntegrityViolationException} → 500. Do not delete as + * "duplicate validation". + */ + private void validateTitleLength(TimelineEventRequest request) { + if (request.title() != null && request.title().length() > MAX_TITLE_LENGTH) { + throw DomainException.badRequest(ErrorCode.TIMELINE_TITLE_TOO_LONG, + "Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters"); + } + } + + private DatePrecision effectivePrecision(TimelineEventRequest request) { + return request.precision() != null ? request.precision() : DatePrecision.YEAR; + } + + private LocalDate normalizeEventDate(LocalDate eventDate, DatePrecision precision) { + return precision == DatePrecision.YEAR ? LocalDate.of(eventDate.getYear(), 1, 1) : eventDate; + } + + // --- link resolution (fail-closed, dedupe-first) --- + + private Set resolvePersons(List ids) { + if (ids == null || ids.isEmpty()) { + return new HashSet<>(); + } + // Dedupe FIRST: [idA, idA] is one link, not a 404. findAllById dedupes too, so compare the + // resolved size against the DISTINCT input count — a raw ids.size() compare reports a spurious + // mismatch. + Set distinct = new LinkedHashSet<>(ids); + List resolved = personService.getAllById(new ArrayList<>(distinct)); + if (resolved.size() != distinct.size()) { + throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found"); + } + return new HashSet<>(resolved); + } + + private Set resolveDocuments(List ids) { + if (ids == null || ids.isEmpty()) { + return new HashSet<>(); + } + // Per-id loop on purpose: DocumentService has no batch fetch, and per-id gives free + // DOCUMENT_NOT_FOUND 404s. getDocumentById is @Transactional(readOnly = true) and joins this + // write tx via Spring's default REQUIRED propagation — do NOT "optimize" into a phantom batch. + Set resolved = new HashSet<>(); + for (UUID documentId : new LinkedHashSet<>(ids)) { + resolved.add(documentService.getDocumentById(documentId)); + } + return resolved; + } + + // --- view assembly (explicit allow-list; never the raw entity) --- + + private TimelineEventView toView(TimelineEvent event) { + List persons = event.getPersons().stream() + .map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName())) + .toList(); + List documents = event.getDocuments().stream() + .map(d -> new DocumentRef(d.getId(), d.getTitle(), d.getDocumentDate())) + .toList(); + return new TimelineEventView( + event.getId(), event.getTitle(), event.getType(), event.getEventDate(), + event.getPrecision(), event.getEventDateEnd(), event.getDescription(), event.getVersion(), + event.getCreatedBy(), event.getCreatedAt(), event.getUpdatedBy(), event.getUpdatedAt(), + persons, documents); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java new file mode 100644 index 00000000..90a418e5 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java @@ -0,0 +1,403 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +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.person.Person; +import org.raddatz.familienarchiv.person.PersonService; + +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +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.AdditionalAnswers.returnsFirstArg; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TimelineEventServiceTest { + + @Mock TimelineEventRepository events; + @Mock PersonService personService; + @Mock DocumentService documentService; + @InjectMocks TimelineEventService service; + + private final UUID actor = UUID.randomUUID(); + private final UUID secondEditor = UUID.randomUUID(); + + /** Mirrors #774's makeEvent defaults so NOT NULL createdBy/updatedBy aren't tripped for the wrong reason. */ + private TimelineEventRequest baseRequest() { + return new TimelineEventRequest( + "Hochzeit von Anna und Otto", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, null); + } + + private Person makePerson(UUID id) { + return Person.builder().id(id).firstName("Anna").lastName("Müller").build(); + } + + private Document makeDocument(UUID id) { + return Document.builder().id(id).title("Brief an Anna").documentDate(LocalDate.of(1914, 6, 1)).build(); + } + + /** A managed, persisted event for update/delete/get paths — version 5, distinct creator. */ + private TimelineEvent existingEvent(UUID id, UUID creator) { + return TimelineEvent.builder() + .id(id).title("Original").type(EventType.PERSONAL).eventDate(LocalDate.of(1914, 1, 1)) + .precision(DatePrecision.YEAR).createdBy(creator).updatedBy(creator).version(5L) + .build(); + } + + /** Stubs saveAndFlush to mimic Hibernate setting version=0 on insert; returns the same managed entity. */ + private void stubFlushSetsVersion() { + when(events.saveAndFlush(any())).thenAnswer(inv -> { + TimelineEvent e = inv.getArgument(0); + if (e.getVersion() == null) e.setVersion(0L); + return e; + }); + } + + private TimelineEvent captureSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(TimelineEvent.class); + verify(events).saveAndFlush(captor.capture()); + return captor.getValue(); + } + + // --- create --- + + @Test + void create_persists_and_sets_createdBy_and_updatedBy_from_actorId() { + stubFlushSetsVersion(); + + TimelineEventView view = service.create(baseRequest(), actor); + + TimelineEvent persisted = captureSaved(); + assertThat(persisted.getCreatedBy()).isEqualTo(actor); + assertThat(persisted.getUpdatedBy()).isEqualTo(actor); + assertThat(view.title()).isEqualTo("Hochzeit von Anna und Otto"); + assertThat(view.version()).isEqualTo(0L); + } + + @Test + void create_defaults_precision_to_YEAR_when_omitted() { + stubFlushSetsVersion(); + + TimelineEventView view = service.create(baseRequest(), actor); + + assertThat(view.precision()).isEqualTo(DatePrecision.YEAR); + } + + @Test + void create_normalizes_eventDate_to_first_of_january_when_precision_is_YEAR() { + stubFlushSetsVersion(); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + DatePrecision.YEAR, null, null, null, null, null); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.eventDate()).isEqualTo(LocalDate.of(1914, 1, 1)); + } + + @Test + void create_with_null_link_lists_yields_empty_collections_no_npe() { + stubFlushSetsVersion(); + + TimelineEventView view = service.create(baseRequest(), actor); + + assertThat(view.persons()).isEmpty(); + assertThat(view.documents()).isEmpty(); + } + + // --- RANGE invariant (both directions are separate tests; each asserts saveAndFlush never called) --- + + @Test + void create_rejects_eventDateEnd_when_precision_is_not_RANGE() { + TimelineEventRequest request = new TimelineEventRequest( + "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1), + DatePrecision.YEAR, LocalDate.of(1918, 1, 1), null, null, null, null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void create_rejects_RANGE_with_null_eventDateEnd() { + TimelineEventRequest request = new TimelineEventRequest( + "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1), + DatePrecision.RANGE, null, null, null, null, null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(events, never()).saveAndFlush(any()); + } + + // --- title-length service guard --- + + @Test + void create_rejects_title_longer_than_255_with_TIMELINE_TITLE_TOO_LONG() { + String overLong = "x".repeat(256); + TimelineEventRequest request = new TimelineEventRequest( + overLong, EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_TITLE_TOO_LONG); + verify(events, never()).saveAndFlush(any()); + } + + // --- link resolution (fail-closed, dedupe-first) --- + + @Test + void create_resolves_persons_and_documents() { + stubFlushSetsVersion(); + UUID personId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId))); + when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId)); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(personId), List.of(docId)); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.persons()).singleElement().satisfies(p -> assertThat(p.id()).isEqualTo(personId)); + assertThat(view.documents()).singleElement().satisfies(d -> assertThat(d.id()).isEqualTo(docId)); + } + + @Test + void create_with_duplicate_personIds_resolves_to_single_link_not_404() { + stubFlushSetsVersion(); + UUID personId = UUID.randomUUID(); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId))); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(personId, personId), null); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.persons()).hasSize(1); + } + + @Test + void create_with_duplicate_documentIds_resolves_to_single_link_not_404() { + stubFlushSetsVersion(); + UUID docId = UUID.randomUUID(); + when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId)); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, List.of(docId, docId)); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.documents()).hasSize(1); + } + + @Test + void create_rejects_unknown_personId_with_PERSON_NOT_FOUND_without_saving() { + UUID known = UUID.randomUUID(); + UUID unknown = UUID.randomUUID(); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(known))); // one missing + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(known, unknown), null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.PERSON_NOT_FOUND); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void create_rejects_unknown_documentId_with_DOCUMENT_NOT_FOUND_without_saving() { + UUID unknown = UUID.randomUUID(); + when(documentService.getDocumentById(unknown)) + .thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + unknown)); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, List.of(unknown)); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND); + verify(events, never()).saveAndFlush(any()); + } + + // --- update --- + + @Test + void update_replaces_links_preserves_createdBy_and_advances_updatedBy_to_second_editor() { + UUID id = UUID.randomUUID(); + UUID creator = UUID.randomUUID(); + UUID newPersonId = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, creator); + existing.getPersons().add(makePerson(UUID.randomUUID())); // pre-existing link to be replaced + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(newPersonId))); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(newPersonId), null); + + service.update(id, request, secondEditor); + + assertThat(existing.getCreatedBy()).isEqualTo(creator); + assertThat(existing.getUpdatedBy()).isEqualTo(secondEditor); + assertThat(existing.getPersons()).singleElement() + .satisfies(p -> assertThat(p.getId()).isEqualTo(newPersonId)); + } + + @Test + void update_with_empty_link_lists_clears_all_links() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + existing.getPersons().add(makePerson(UUID.randomUUID())); + existing.getDocuments().add(makeDocument(UUID.randomUUID())); + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(), List.of()); + + service.update(id, request, secondEditor); + + assertThat(existing.getPersons()).isEmpty(); + assertThat(existing.getDocuments()).isEmpty(); + } + + @Test + void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() { + UUID id = UUID.randomUUID(); + when(events.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.update(id, baseRequest(), actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND); + } + + // --- version / optimistic lock --- + + @Test + void update_with_null_version_still_invokes_saveAndFlush() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + + service.update(id, baseRequest(), secondEditor); // baseRequest has null version + + verify(events).saveAndFlush(existing); + } + + @Test + void update_with_null_version_does_not_stamp_version_last_write_wins() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = spy(existingEvent(id, UUID.randomUUID())); + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + + service.update(id, baseRequest(), secondEditor); // null version + + verify(existing, never()).setVersion(anyLong()); + } + + @Test + void update_with_in_sync_version_applies_it_before_saving() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = spy(existingEvent(id, UUID.randomUUID())); + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, 5L, null, null); + + service.update(id, request, secondEditor); + + verify(existing).setVersion(5L); + } + + @Test + void update_with_stale_version_translates_lock_failure_to_TIMELINE_EVENT_CONFLICT() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())) + .thenThrow(new ObjectOptimisticLockingFailureException(TimelineEvent.class, id)); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, 2L, null, null); + + assertThatThrownBy(() -> service.update(id, request, secondEditor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT); + } + + // --- delete / getEvent --- + + @Test + void delete_removes_existing_event() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + when(events.findById(id)).thenReturn(Optional.of(existing)); + + service.delete(id); + + verify(events).delete(existing); + } + + @Test + void delete_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() { + UUID id = UUID.randomUUID(); + when(events.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.delete(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND); + } + + @Test + void getEvent_returns_view_for_existing_event() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + when(events.findById(id)).thenReturn(Optional.of(existing)); + + TimelineEventView view = service.getEvent(id); + + assertThat(view.id()).isEqualTo(id); + assertThat(view.title()).isEqualTo("Original"); + } + + @Test + void getEvent_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() { + UUID id = UUID.randomUUID(); + when(events.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEvent(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND); + } +}