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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user