feat(timeline): add TimelineEventService with CRUD + view assembly

create/update/delete write methods (@Transactional) + getEvent read
(@Transactional(readOnly=true) for the LazyInit guard). Persons resolved via
PersonService.getAllById with a distinct-size check; documents via per-id
DocumentService.getDocumentById loop; both dedupe-first, fail-closed. RANGE
invariant (both directions), title-length guard, YEAR date normalization, and
default precision. Audit fields server-set (createdBy+updatedBy on create;
only updatedBy on update). Optimistic-lock conflict translated to
TIMELINE_EVENT_CONFLICT via saveAndFlush+catch. Views assembled after flush.

Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 10:52:00 +02:00
committed by marcel
parent b7a5cd7b53
commit c51fc5e79f
2 changed files with 622 additions and 0 deletions

View File

@@ -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<Person> resolvePersons(List<UUID> 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<UUID> distinct = new LinkedHashSet<>(ids);
List<Person> 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<Document> resolveDocuments(List<UUID> 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<Document> 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<PersonView> persons = event.getPersons().stream()
.map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
.toList();
List<DocumentRef> 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);
}
}

View File

@@ -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<TimelineEvent> 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);
}
}