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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<TimelineYearDTO> years,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> undated
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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<TimelineEntryDTO> 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<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
||||||
|
|
||||||
|
// ── curated events ───────────────────────────────────────────────────
|
||||||
|
List<TimelineEntryDTO> 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<Document> 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<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
|
||||||
|
Map<Integer, List<TimelineEntryDTO>> 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<TimelineEntryDTO> entries) {
|
||||||
|
List<TimelineEntryDTO> undated = entries.stream()
|
||||||
|
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
|
||||||
|
.sorted(WITHIN_BAND_ORDER)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
|
||||||
|
List<TimelineYearDTO> 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<Document> fetchDocuments(UUID personId) {
|
||||||
|
if (personId == null) {
|
||||||
|
return documentService.getAllForTimeline();
|
||||||
|
}
|
||||||
|
// personId path: validate existence, then union sender+receiver (dedup by id)
|
||||||
|
personService.getById(personId);
|
||||||
|
Map<UUID, Document> 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<Person> persons, UUID personId) {
|
||||||
|
if (personId == null) return true;
|
||||||
|
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
|
||||||
|
if (personId == null) return true;
|
||||||
|
return linkedIds != null && linkedIds.contains(personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
|
||||||
|
if (generation == null) return null;
|
||||||
|
return personService.getPersonsByGeneration(generation).stream()
|
||||||
|
.map(Person::getId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> 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<UUID> linkedIds, Set<UUID> 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<UUID> genPersonIds) {
|
||||||
|
if (genPersonIds == null) return true;
|
||||||
|
Person sender = doc.getSender();
|
||||||
|
if (sender != null && genPersonIds.contains(sender.getId())) return true;
|
||||||
|
Set<Person> receivers = doc.getReceivers();
|
||||||
|
if (receivers != null) {
|
||||||
|
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mapping ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
|
||||||
|
List<UUID> 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<Person> 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TimelineEntryDTO> entries
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -100,6 +100,13 @@ class ArchitectureTest {
|
|||||||
.and().resideInAPackage("..audit..")
|
.and().resideInAPackage("..audit..")
|
||||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("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.
|
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
|
||||||
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
||||||
// where it can be audited and reasoned about independently.
|
// where it can be audited and reasoned about independently.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user