diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java index e7c0d892..3a50d4bf 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -10,6 +10,8 @@ import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.person.relationship.PersonRelationship; +import org.raddatz.familienarchiv.person.relationship.RelationshipService; import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef; import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView; @@ -40,6 +42,7 @@ public class TimelineEventService { private final TimelineEventRepository events; private final PersonService personService; private final DocumentService documentService; + private final RelationshipService relationshipService; @Transactional public TimelineEventView create(TimelineEventRequest request, UUID actorId) { @@ -229,6 +232,91 @@ public class TimelineEventService { return resolved; } + // --- derived event assembly --- + + /** + * Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and + * PersonRelationship data. Computed on read, never persisted. + * + *

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 assembleDerivedEvents() { + List persons = personService.findAllFamilyMembers(); + List spouseEdges = relationshipService.findAllSpouseEdges(); + + List 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 buildBirthEvents(List 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 buildDeathEvents(List 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 buildMarriageEvents(List spouseEdges) { + // DB constraint unique_spouse_pair (V55) is the authoritative enforcement; + // in-memory dedup on relationship row id is a defensive assertion. + Set seen = new HashSet<>(); + List 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) --- private TimelineEventView toView(TimelineEvent event) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java new file mode 100644 index 00000000..9566b1fd --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java @@ -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 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 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 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 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 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 result = service.assembleDerivedEvents(); + + List 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 result = service.assembleDerivedEvents(); + + List 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 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 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 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 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 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 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 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 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 result = service.assembleDerivedEvents(); + + assertThat(result).isEmpty(); + } +}