From f08b09faeb7d1b3539968401876b4a8c4693769f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:20:54 +0200 Subject: [PATCH] feat(timeline): add TimelineService assembly + 24-test Mockito suite Creates TimelineService.assemble(TimelineFilter): merges curated events (TimelineEventRepository), derived life-events (assembleDerivedEvents()), and archive letters (DocumentService) into a year-bucketed TimelineDTO. WITHIN_BAND_ORDER Comparator tested standalone before assembly tests. ArchUnit Rule 2 entry for ..timeline.. domain added in same commit. Refs #777 Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/timeline/TimelineDTO.java | 15 + .../timeline/TimelineFilter.java | 16 + .../timeline/TimelineService.java | 260 ++++++++++ .../timeline/TimelineYearDTO.java | 12 + .../shared/ArchitectureTest.java | 7 + .../timeline/TimelineServiceTest.java | 452 ++++++++++++++++++ 6 files changed, 762 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java new file mode 100644 index 00000000..bffe72fa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.timeline; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * Assembled timeline response. Year bands are sorted ascending (oldest first). + * Undated entries have no usable date or {@code UNKNOWN} precision. + */ +public record TimelineDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List years, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List undated +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java new file mode 100644 index 00000000..0be25434 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.timeline; + +import java.util.UUID; + +/** + * Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}. + * All fields are nullable — null means "no constraint on this dimension". + */ +public record TimelineFilter( + UUID personId, + Integer generation, + EventType type, + Integer fromYear, + Integer toYear +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java new file mode 100644 index 00000000..50b008bd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java @@ -0,0 +1,260 @@ +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.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Assembles the family timeline from three sources — curated {@link TimelineEvent} rows, + * derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}. + * + *

Cross-domain data is reached exclusively through domain services (PersonService, + * DocumentService). The only repository injected directly is {@link TimelineEventRepository} + * (same domain — constitution §1.3). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TimelineService { + + /** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */ + static final Comparator WITHIN_BAND_ORDER = + Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed() + .thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX) + .thenComparing(e -> e.title() != null ? e.title() : "") + .thenComparing(e -> { + if (e.eventId() != null) return e.eventId().toString(); + if (e.documentId() != null) return e.documentId().toString(); + return ""; + }); + + private final TimelineEventRepository eventRepository; + private final TimelineEventService timelineEventService; + private final DocumentService documentService; + private final PersonService personService; + + /** + * Assembles the timeline for the given filter. All filters are ANDed. + * Throws {@link DomainException} (bad request) when fromYear > toYear. + * Throws {@link DomainException} (not found) when personId refers to an unknown person. + */ + public TimelineDTO assemble(TimelineFilter filter) { + if (filter.fromYear() != null && filter.toYear() != null + && filter.fromYear() > filter.toYear()) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "toYear must not be before fromYear"); + } + + // Resolve generation person IDs once — used across all three layers + Set genPersonIds = resolveGenerationPersonIds(filter.generation()); + + // ── curated events ─────────────────────────────────────────────────── + List entries = new ArrayList<>(); + for (TimelineEvent ev : eventRepository.findAll()) { + if (!passesTypeFilter(ev.getType(), filter.type())) continue; + if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue; + if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue; + if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue; + entries.add(mapEvent(ev)); + } + + // ── derived events ─────────────────────────────────────────────────── + for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) { + if (!passesTypeFilter(derived.type(), filter.type())) continue; + if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue; + if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue; + if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue; + entries.add(derived); + } + + // ── letters ───────────────────────────────────────────────────────── + List docs = fetchDocuments(filter.personId()); + for (Document doc : docs) { + if (!passesLetterGenerationFilter(doc, genPersonIds)) continue; + if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue; + entries.add(mapDocument(doc)); + } + + return bucket(entries); + } + + // ─── Bucketing ─────────────────────────────────────────────────────────── + + Map> bucketByYear(List entries) { + Map> map = new TreeMap<>(); + for (TimelineEntryDTO e : entries) { + if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue; + map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e); + } + return map; + } + + private TimelineDTO bucket(List entries) { + List undated = entries.stream() + .filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) + .sorted(WITHIN_BAND_ORDER) + .toList(); + + Map> byYear = bucketByYear(entries); + List years = byYear.entrySet().stream() + .map(e -> new TimelineYearDTO(e.getKey(), + e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList())) + .toList(); + + return new TimelineDTO(years, undated); + } + + // ─── Document fetch (global vs personId path) ──────────────────────────── + + private List fetchDocuments(UUID personId) { + if (personId == null) { + return documentService.getAllForTimeline(); + } + // personId path: validate existence, then union sender+receiver (dedup by id) + personService.getById(personId); + Map seen = new LinkedHashMap<>(); + for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d); + for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d); + return new ArrayList<>(seen.values()); + } + + // ─── Filter predicates ─────────────────────────────────────────────────── + + private boolean passesTypeFilter(EventType entryType, EventType filterType) { + return filterType == null || filterType == entryType; + } + + private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) { + if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes + int year = date.getYear(); + if (filter.fromYear() != null && year < filter.fromYear()) return false; + if (filter.toYear() != null && year > filter.toYear()) return false; + return true; + } + + private boolean passesPersonFilter(Set persons, UUID personId) { + if (personId == null) return true; + return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId())); + } + + private boolean passesDerivedPersonFilter(List linkedIds, UUID personId) { + if (personId == null) return true; + return linkedIds != null && linkedIds.contains(personId); + } + + private Set resolveGenerationPersonIds(Integer generation) { + if (generation == null) return null; + return personService.getPersonsByGeneration(generation).stream() + .map(Person::getId) + .collect(Collectors.toSet()); + } + + private boolean passesGenerationFilter(Set persons, Set genPersonIds) { + if (genPersonIds == null) return true; + if (persons == null || persons.isEmpty()) return false; + return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId())); + } + + private boolean passesDerivedGenerationFilter(List linkedIds, Set genPersonIds) { + if (genPersonIds == null) return true; + if (linkedIds == null || linkedIds.isEmpty()) return false; + return linkedIds.stream().anyMatch(genPersonIds::contains); + } + + private boolean passesLetterGenerationFilter(Document doc, Set genPersonIds) { + if (genPersonIds == null) return true; + Person sender = doc.getSender(); + if (sender != null && genPersonIds.contains(sender.getId())) return true; + Set receivers = doc.getReceivers(); + if (receivers != null) { + return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId())); + } + return false; + } + + // ─── Mapping ───────────────────────────────────────────────────────────── + + private TimelineEntryDTO mapEvent(TimelineEvent ev) { + List personIds = ev.getPersons() == null ? List.of() + : ev.getPersons().stream().map(Person::getId).toList(); + return new TimelineEntryDTO( + Kind.EVENT, + ev.getPrecision(), + false, + "", + "", + ev.getEventDate(), + ev.getEventDateEnd(), + ev.getTitle(), + ev.getType(), + ev.getId(), + null, + personIds, + null + ); + } + + private TimelineEntryDTO mapDocument(Document doc) { + return new TimelineEntryDTO( + Kind.LETTER, + doc.getMetaDatePrecision(), + false, + resolveSenderName(doc), + resolveReceiverName(doc), + doc.getDocumentDate(), + null, + doc.getTitle(), + null, + null, + doc.getId(), + List.of(), + null + ); + } + + private String resolveSenderName(Document doc) { + if (doc.getSender() != null) return doc.getSender().getDisplayName(); + String text = doc.getSenderText(); + return (text != null && !text.isBlank()) ? text : ""; + } + + private String resolveReceiverName(Document doc) { + Set receivers = doc.getReceivers(); + if (receivers != null && !receivers.isEmpty()) { + return receivers.stream().findFirst().map(Person::getDisplayName).orElse(""); + } + String text = doc.getReceiverText(); + return (text != null && !text.isBlank()) ? text : ""; + } + + private static int precisionRank(DatePrecision precision) { + if (precision == null) return 0; + return switch (precision) { + case DAY -> 5; + case MONTH -> 4; + case SEASON -> 3; + case YEAR -> 2; + case APPROX -> 1; + default -> 0; + }; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java new file mode 100644 index 00000000..bc9c9772 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.timeline; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */ +public record TimelineYearDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List entries +) { +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java index 0d34b257..0adffde0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java @@ -100,6 +100,13 @@ class ArchitectureTest { .and().resideInAPackage("..audit..") .should().dependOnClassesThat(foreignJpaRepositoryFor("audit")); + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_timeline = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..timeline..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("timeline")); + // Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages. // Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages // where it can be audited and reasoned about independently. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java new file mode 100644 index 00000000..549c4f10 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java @@ -0,0 +1,452 @@ +package org.raddatz.familienarchiv.timeline; + +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.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.person.Person; +import org.raddatz.familienarchiv.person.PersonService; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TimelineServiceTest { + + @Mock TimelineEventRepository eventRepository; + @Mock TimelineEventService timelineEventService; + @Mock DocumentService documentService; + @Mock PersonService personService; + + @InjectMocks TimelineService timelineService; + + // ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ──────────────────────── + + @Test + void within_band_order_day_precision_sorts_before_year() { + var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief"); + var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief"); + + var sorted = List.of(yearEntry, dayEntry).stream() + .sorted(TimelineService.WITHIN_BAND_ORDER) + .toList(); + + assertThat(sorted).containsExactly(dayEntry, yearEntry); + } + + @Test + void within_band_order_same_precision_and_date_sorts_alphabetically() { + var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer"); + var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler"); + + var sorted = List.of(entryZ, entryA).stream() + .sorted(TimelineService.WITHIN_BAND_ORDER) + .toList(); + + assertThat(sorted).containsExactly(entryA, entryZ); + } + + @Test + void within_band_order_same_title_uses_document_id_as_tiebreak() { + UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002"); + var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "", + LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null); + var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "", + LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null); + + var sorted = List.of(e2, e1).stream() + .sorted(TimelineService.WITHIN_BAND_ORDER) + .toList(); + + assertThat(sorted.get(0).documentId()).isEqualTo(id1); + } + + // ─── Assembly tests (issue-spec order) ────────────────────────────────── + + @Test + void test1_empty_archive_returns_empty_dto() { + // REQ-013, REQ-007 + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test2_one_year_letter_returns_one_year_band() { + // REQ-007 + var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + assertThat(result.years().get(0).entries()).hasSize(1); + assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test3a_null_date_letter_goes_to_undated() { + // REQ-003 + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).hasSize(1); + } + + @Test + void test3b_unknown_precision_letter_goes_to_undated() { + // REQ-003 + var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).hasSize(1); + } + + @Test + void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() { + // REQ-005 + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR) + .documentDate(LocalDate.of(1914, 1, 1)) + .build(); // no sender, no senderText, no receivers, no receiverText + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + var entry = result.years().get(0).entries().get(0); + assertThat(entry.senderName()).isEqualTo(""); + assertThat(entry.receiverName()).isEqualTo(""); + } + + @Test + void test5_day_precision_sorts_before_year_in_same_year_band() { + // REQ-002 + var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief"); + var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + var entries = result.years().get(0).entries(); + assertThat(entries).hasSize(2); + assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY); + assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR); + } + + @Test + void test6_same_precision_same_date_sorted_alphabetically_by_title() { + // REQ-002 + var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer"); + var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + var entries = result.years().get(0).entries(); + assertThat(entries).hasSize(2); + assertThat(entries.get(0).title()).isEqualTo("Adler"); + assertThat(entries.get(1).title()).isEqualTo("Zimmer"); + } + + @Test + void test7a_range_event_placed_only_in_start_year_band() { + // REQ-004 + var rangeEvent = event("WW1", EventType.HISTORICAL, + LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11)); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue(); + } + + @Test + void test7b_range_event_with_null_eventDateEnd_does_not_crash() { + // REQ-004 + var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL, + LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException(); + } + + @Test + void test8_range_event_excluded_when_start_year_before_fromYear() { + // REQ-004 + var rangeEvent = event("WW1", EventType.HISTORICAL, + LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11)); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + // fromYear=1915 → start year 1914 is outside → excluded + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null)); + + assertThat(result.years()).isEmpty(); + } + + @Test + void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() { + // REQ-009 + var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief"); + var historicalEvent = event("Sarajevo", EventType.HISTORICAL, + LocalDate.of(1914, 6, 28), DatePrecision.DAY, null); + var personalEvent = event("Geburt", EventType.PERSONAL, + LocalDate.of(1914, 8, 1), DatePrecision.DAY, null); + when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + // filter: only HISTORICAL events + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null)); + + long letters = result.years().stream().flatMap(y -> y.entries().stream()) + .filter(e -> e.kind() == Kind.LETTER).count(); + long personalEvents = result.years().stream().flatMap(y -> y.entries().stream()) + .filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count(); + assertThat(letters).isEqualTo(1); + assertThat(personalEvents).isEqualTo(0); + } + + @Test + void test9b_generation_filter_includes_letter_when_sender_matches_generation() { + // REQ-010 + var sender = Person.builder().id(UUID.randomUUID()) + .lastName("Mustermann").firstName("Max").generation(2).build(); + var included = Document.builder().id(UUID.randomUUID()).title("Treffer") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(sender).build(); + var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded)); + when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null)); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).entries()).hasSize(1); + assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer"); + } + + @Test + void test9c_fromYear_toYear_inclusive_single_year_window() { + // REQ-011 + var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher"); + var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr"); + var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914)); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr"); + } + + @Test + void test10_adversarial_and_logic_neither_event_passes_both_filters() { + // REQ-012 — type AND year must both pass + var wrongType = event("Personal", EventType.PERSONAL, + LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null); + var wrongYear = event("Historical outside", EventType.HISTORICAL, + LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null); + when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914)); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() { + // REQ-008 + UUID personId = UUID.randomUUID(); + var person = Person.builder().id(personId).lastName("Mustermann").build(); + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(person) + .receivers(Set.of(person)) + .build(); + when(personService.getById(personId)).thenReturn(person); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc)); + when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null)); + + long total = result.years().stream().mapToLong(y -> y.entries().size()).sum() + + result.undated().size(); + assertThat(total).isEqualTo(1); + } + + @Test + void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() { + // REQ-012 + UUID personId = UUID.randomUUID(); + var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build(); + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(person).build(); + var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build(); + when(personService.getById(personId)).thenReturn(person); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc)); + when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of()); + when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2 + + TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null)); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test13_null_generation_sender_not_returned_by_generation_filter() { + // REQ-020 — both sender and receiver have null generation → excluded + var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(nullGenSender).build(); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1 + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null)); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test14_year_band_contains_only_event_when_no_letters_in_that_year() { + var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null); + when(eventRepository.findAll()).thenReturn(List.of(ev)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).entries()).hasSize(1); + assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT); + } + + @Test + void test15_range_event_start_year_equal_to_fromYear_is_included() { + // REQ-004 — inclusive lower bound + var rangeEvent = event("WW1", EventType.HISTORICAL, + LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11)); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null)); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + } + + @Test + void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() { + // REQ-011 + var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt"); + var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst"); + var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null)); + + assertThat(result.years()).hasSize(2); + assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue(); + } + + @Test + void fromYear_greater_than_toYear_throws_bad_request() { + // REQ-016 (service-layer guard) + assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914))) + .isInstanceOf(DomainException.class); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private static TimelineFilter noFilters() { + return new TimelineFilter(null, null, null, null, null); + } + + private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) { + return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "", + date, null, title, null, null, UUID.randomUUID(), List.of(), null); + } + + private static Document docWithDate(LocalDate date, DatePrecision precision) { + return Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(precision).documentDate(date).build(); + } + + private static Document docWithDate(LocalDate date, DatePrecision precision, String title) { + return Document.builder().id(UUID.randomUUID()).title(title) + .metaDatePrecision(precision).documentDate(date).build(); + } + + private static TimelineEvent event(String title, EventType type, LocalDate date, + DatePrecision precision, LocalDate endDate) { + return TimelineEvent.builder().id(UUID.randomUUID()) + .title(title).type(type) + .eventDate(date).precision(precision).eventDateEnd(endDate) + .build(); + } +}