feat(timeline): implement assembleDerivedEvents() with TDD (REQ-001–REQ-016)
Adds RelationshipService dependency to TimelineEventService and implements: - assembleDerivedEvents() — public @Transactional(readOnly=true) orchestrator - buildBirthEvents() — Person.birthDate → BIRTH events with precision pass-through - buildDeathEvents() — Person.deathDate → DEATH events with precision pass-through - buildMarriageEvents() — SPOUSE_OF edges → MARRIAGE events, dedup on row id Synthetic prefixed ids (birth:/death:/marriage:) are structurally non-UUID. Null fromYear marriages are emitted with eventDate=null + precision=UNKNOWN (REQ-006). Non-family-member persons excluded from birth/death; SPOUSE_OF edges always emit (REQ-013). All 16 tests in DerivedEventsAssemblyTest pass. Refs #776 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
|
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
|
||||||
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
|
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ public class TimelineEventService {
|
|||||||
private final TimelineEventRepository events;
|
private final TimelineEventRepository events;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
private final RelationshipService relationshipService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
|
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
|
||||||
@@ -229,6 +232,91 @@ public class TimelineEventService {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- derived event assembly ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
|
||||||
|
* PersonRelationship data. Computed on read, never persisted.
|
||||||
|
*
|
||||||
|
* <p>Derived events are computed, never persisted, and cannot be mutated via the events API
|
||||||
|
* (enforced in #5). Ids produced by this method are structurally non-UUID
|
||||||
|
* ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
|
||||||
|
* write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
|
||||||
|
* independently enforce {@code READ_ALL} authorization before invoking this method
|
||||||
|
* (see ADR-043).
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<TimelineEntryDTO> assembleDerivedEvents() {
|
||||||
|
List<Person> persons = personService.findAllFamilyMembers();
|
||||||
|
List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = new ArrayList<>();
|
||||||
|
result.addAll(buildBirthEvents(persons));
|
||||||
|
result.addAll(buildDeathEvents(persons));
|
||||||
|
result.addAll(buildMarriageEvents(spouseEdges));
|
||||||
|
|
||||||
|
log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TimelineEntryDTO> buildBirthEvents(List<Person> persons) {
|
||||||
|
return persons.stream()
|
||||||
|
.filter(p -> p.getBirthDate() != null)
|
||||||
|
.map(p -> new TimelineEntryDTO(
|
||||||
|
"birth:" + p.getId(),
|
||||||
|
EventType.PERSONAL,
|
||||||
|
p.getBirthDate(),
|
||||||
|
p.getBirthDatePrecision(),
|
||||||
|
true,
|
||||||
|
DerivedEventType.BIRTH,
|
||||||
|
p.getDisplayName(),
|
||||||
|
null))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TimelineEntryDTO> buildDeathEvents(List<Person> persons) {
|
||||||
|
return persons.stream()
|
||||||
|
.filter(p -> p.getDeathDate() != null)
|
||||||
|
.map(p -> new TimelineEntryDTO(
|
||||||
|
"death:" + p.getId(),
|
||||||
|
EventType.PERSONAL,
|
||||||
|
p.getDeathDate(),
|
||||||
|
p.getDeathDatePrecision(),
|
||||||
|
true,
|
||||||
|
DerivedEventType.DEATH,
|
||||||
|
p.getDisplayName(),
|
||||||
|
null))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
|
||||||
|
// DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
|
||||||
|
// in-memory dedup on relationship row id is a defensive assertion.
|
||||||
|
Set<UUID> seen = new HashSet<>();
|
||||||
|
List<TimelineEntryDTO> result = new ArrayList<>();
|
||||||
|
for (PersonRelationship r : spouseEdges) {
|
||||||
|
if (seen.add(r.getId())) {
|
||||||
|
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
|
||||||
|
LocalDate eventDate = r.getFromYear() != null
|
||||||
|
? LocalDate.of(r.getFromYear(), 1, 1)
|
||||||
|
: null;
|
||||||
|
DatePrecision precision = r.getFromYear() != null
|
||||||
|
? DatePrecision.YEAR
|
||||||
|
: DatePrecision.UNKNOWN;
|
||||||
|
result.add(new TimelineEntryDTO(
|
||||||
|
"marriage:" + r.getId(),
|
||||||
|
EventType.PERSONAL,
|
||||||
|
eventDate,
|
||||||
|
precision,
|
||||||
|
true,
|
||||||
|
DerivedEventType.MARRIAGE,
|
||||||
|
r.getPerson().getDisplayName(),
|
||||||
|
r.getRelatedPerson().getDisplayName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// --- view assembly (explicit allow-list; never the raw entity) ---
|
// --- view assembly (explicit allow-list; never the raw entity) ---
|
||||||
|
|
||||||
private TimelineEventView toView(TimelineEvent event) {
|
private TimelineEventView toView(TimelineEvent event) {
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class DerivedEventsAssemblyTest {
|
||||||
|
|
||||||
|
@Mock private TimelineEventRepository events;
|
||||||
|
@Mock private PersonService personService;
|
||||||
|
@Mock private DocumentService documentService;
|
||||||
|
@Mock private RelationshipService relationshipService;
|
||||||
|
|
||||||
|
@InjectMocks private TimelineEventService service;
|
||||||
|
|
||||||
|
// --- factory helpers ---
|
||||||
|
|
||||||
|
private Person makePerson(LocalDate birthDate, DatePrecision birthPrecision) {
|
||||||
|
return Person.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.firstName("Anna")
|
||||||
|
.lastName("Müller")
|
||||||
|
.familyMember(true)
|
||||||
|
.birthDate(birthDate)
|
||||||
|
.birthDatePrecision(birthPrecision)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person makePersonWithDeath(LocalDate deathDate, DatePrecision deathPrecision) {
|
||||||
|
return Person.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.firstName("Hans")
|
||||||
|
.lastName("Raddatz")
|
||||||
|
.familyMember(true)
|
||||||
|
.deathDate(deathDate)
|
||||||
|
.deathDatePrecision(deathPrecision)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person makePersonWithBoth(
|
||||||
|
LocalDate birthDate, DatePrecision birthPrecision,
|
||||||
|
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||||
|
return Person.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.firstName("Anna")
|
||||||
|
.lastName("Müller")
|
||||||
|
.familyMember(true)
|
||||||
|
.birthDate(birthDate)
|
||||||
|
.birthDatePrecision(birthPrecision)
|
||||||
|
.deathDate(deathDate)
|
||||||
|
.deathDatePrecision(deathPrecision)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person makeNonFamilyPerson(LocalDate birthDate, DatePrecision precision) {
|
||||||
|
return Person.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.firstName("Anna")
|
||||||
|
.lastName("Müller")
|
||||||
|
.familyMember(false)
|
||||||
|
.birthDate(birthDate)
|
||||||
|
.birthDatePrecision(precision)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
||||||
|
return PersonRelationship.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.person(a)
|
||||||
|
.relatedPerson(b)
|
||||||
|
.relationType(RelationType.SPOUSE_OF)
|
||||||
|
.fromYear(fromYear)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-001: birth events ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_one_geburt_for_person_with_birthdate() {
|
||||||
|
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
TimelineEntryDTO event = result.get(0);
|
||||||
|
assertThat(event.derived()).isTrue();
|
||||||
|
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
|
||||||
|
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
|
||||||
|
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
|
||||||
|
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
assertThat(event.primaryPersonName()).isEqualTo(anna.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-003: null birthDate → no Geburt event ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate() {
|
||||||
|
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
long todCount = result.stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.DEATH)
|
||||||
|
.count();
|
||||||
|
assertThat(todCount).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-004: null deathDate → no Tod event ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_no_events_for_person_with_neither_date() {
|
||||||
|
Person nobody = Person.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.firstName("Hans")
|
||||||
|
.lastName("Raddatz")
|
||||||
|
.familyMember(true)
|
||||||
|
.build();
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(nobody));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-002: death events ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_one_tod_for_person_with_deathdate() {
|
||||||
|
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
TimelineEntryDTO event = result.get(0);
|
||||||
|
assertThat(event.derived()).isTrue();
|
||||||
|
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
|
||||||
|
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
|
||||||
|
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
|
||||||
|
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
assertThat(event.primaryPersonName()).isEqualTo(hans.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-002 + REQ-003 combined ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only() {
|
||||||
|
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.YEAR);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).derivedType()).isEqualTo(DerivedEventType.DEATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-005: Heirat with fromYear ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_one_heirat_for_spouse_edge_with_fromYear() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> heiraten = result.stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.toList();
|
||||||
|
assertThat(heiraten).hasSize(1);
|
||||||
|
TimelineEntryDTO heirat = heiraten.get(0);
|
||||||
|
assertThat(heirat.derived()).isTrue();
|
||||||
|
assertThat(heirat.type()).isEqualTo(EventType.PERSONAL);
|
||||||
|
assertThat(heirat.derivedType()).isEqualTo(DerivedEventType.MARRIAGE);
|
||||||
|
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1930, 1, 1));
|
||||||
|
assertThat(heirat.precision()).isEqualTo(DatePrecision.YEAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-006: Heirat with null fromYear → emitted with UNKNOWN precision ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_unknown_precision_heirat_when_fromYear_is_null() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge = makeSpouseEdge(anna, hans, null);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> heiraten = result.stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.toList();
|
||||||
|
assertThat(heiraten).hasSize(1);
|
||||||
|
TimelineEntryDTO heirat = heiraten.get(0);
|
||||||
|
assertThat(heirat.eventDate()).isNull();
|
||||||
|
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_exactly_one_heirat_when_both_spouses_in_scope() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person hans = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
long heiratCount = result.stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.count();
|
||||||
|
assertThat(heiratCount).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_two_heirat_for_person_married_to_two_partners() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person hans = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person karl = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge1 = makeSpouseEdge(anna, hans, 1930);
|
||||||
|
PersonRelationship edge2 = makeSpouseEdge(anna, karl, 1945);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans, karl));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge1, edge2));
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
long heiratCount = result.stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.count();
|
||||||
|
assertThat(heiratCount).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-001 precision pass-through ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_pass_birth_precision_through_unchanged() {
|
||||||
|
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).precision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-008: synthetic prefixed ids, never UUID ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_mint_prefixed_synthetic_ids_never_uuid() {
|
||||||
|
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
String id = result.get(0).id();
|
||||||
|
assertThat(id).startsWith("birth:");
|
||||||
|
assertThatThrownBy(() -> UUID.fromString(id))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-010: display names on Heirat ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_heirat_with_displayname_for_both_spouses() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> heiraten = service.assembleDerivedEvents().stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
assertThat(heiraten).hasSize(1);
|
||||||
|
TimelineEntryDTO heirat = heiraten.get(0);
|
||||||
|
assertThat(heirat.primaryPersonName()).isNotNull().isNotBlank();
|
||||||
|
assertThat(heirat.relatedPersonName()).isNotNull().isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-007 note: assumption/documentation test ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void self_spouse_edge_invariant_is_enforced_by_db_constraint() {
|
||||||
|
// Assumption test — documents that the DB constraint prevents self-edges;
|
||||||
|
// the service does not guard this itself.
|
||||||
|
// The unique_spouse_pair index (V55) using LEAST/GREATEST is the authoritative guard.
|
||||||
|
// This test verifies that if an edge were somehow inserted (impossible in prod),
|
||||||
|
// the service would still produce one event (not zero or an exception).
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship selfEdge = makeSpouseEdge(anna, anna, 1930);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(selfEdge));
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
long heiratCount = result.stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.count();
|
||||||
|
assertThat(heiratCount).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-012: non-family-member persons excluded from Geburt/Tod ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_exclude_non_family_member_persons_from_derived_events() {
|
||||||
|
Person nonMember = makeNonFamilyPerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of());
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-013: Heirat emitted even when one spouse has familyMember=false ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_heirat_when_one_spouse_is_not_family_member() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person nonMember = makeNonFamilyPerson(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge = makeSpouseEdge(anna, nonMember, 1930);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
long heiratCount = result.stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.count();
|
||||||
|
assertThat(heiratCount).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REQ-014: empty family-member list → empty result, no error ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_zero_events_when_no_family_members() {
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of());
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user