feat(relationship): store from/to as LocalDate + DatePrecision
Replace person_relationships.from_year/to_year (Integer) with from_date/ to_date (LocalDate) + NOT-NULL from_date_precision/to_date_precision (DatePrecision, default UNKNOWN), mirroring the Person life-date pattern (ADR-039 / V76). V78 backfills existing years as YYYY-01-01 at YEAR precision, adds five named CHECK constraints (coherence both ends, from<=to, precision value sets) and drops the year columns — verified by RelationshipMigrationTest on a real Postgres 16 container. validateRelationshipDates replaces validateYears: coherence (date <=> non- UNKNOWN precision) -> INVALID_DATE_PRECISION, order (toDate before fromDate) -> INVALID_RELATIONSHIP_DATES. The derived Zeitstrahl Heirat event now sources the SPOUSE_OF.from_date at its stored precision, so a DAY-precision wedding surfaces the exact day instead of just the year. RelationshipDTO and the shared create/update request (renamed CreateRelationshipRequest -> RelationshipUpsertRequest) carry the date+precision fields. REQ-001, REQ-002, REQ-003, REQ-010, REQ-011, REQ-014, REQ-017 Refs #837 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
|
|||||||
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
||||||
try {
|
try {
|
||||||
relationshipService.addRelationship(person,
|
relationshipService.addRelationship(person,
|
||||||
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
|
new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null));
|
||||||
return true;
|
return true;
|
||||||
} catch (DomainException e) {
|
} catch (DomainException e) {
|
||||||
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -39,11 +41,25 @@ public class PersonRelationship {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private RelationType relationType;
|
private RelationType relationType;
|
||||||
|
|
||||||
@Column(name = "from_year")
|
// Start/end of the relationship (wedding, employment start, …). The date column
|
||||||
private Integer fromYear;
|
// is nullable, the precision column is NOT NULL with UNKNOWN meaning "no date" —
|
||||||
|
// the V78 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN) and
|
||||||
|
// from_date <= to_date. Mirrors Person.{birth,death}Date (ADR-039 / ADR-044).
|
||||||
|
private LocalDate fromDate;
|
||||||
|
|
||||||
@Column(name = "to_year")
|
@Enumerated(EnumType.STRING)
|
||||||
private Integer toYear;
|
@Column(name = "from_date_precision", nullable = false, length = 16)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
|
private LocalDate toDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "to_date_precision", nullable = false, length = 16)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private DatePrecision toDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
@Column(length = 2000)
|
@Column(length = 2000)
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -63,7 +63,7 @@ public class RelationshipController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<RelationshipDTO> addRelationship(
|
public ResponseEntity<RelationshipDTO> addRelationship(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@Valid @RequestBody CreateRelationshipRequest dto) {
|
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
||||||
return ResponseEntity.status(HttpStatus.CREATED)
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
.body(relationshipService.addRelationship(id, dto));
|
.body(relationshipService.addRelationship(id, dto));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
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.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
@@ -96,7 +97,7 @@ public class RelationshipService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
|
||||||
if (personId.equals(dto.relatedPersonId())) {
|
if (personId.equals(dto.relatedPersonId())) {
|
||||||
throw DomainException.badRequest(
|
throw DomainException.badRequest(
|
||||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||||
@@ -104,7 +105,7 @@ public class RelationshipService {
|
|||||||
Person person = personService.getById(personId);
|
Person person = personService.getById(personId);
|
||||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||||
|
|
||||||
validateYears(dto.fromYear(), dto.toYear());
|
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
||||||
|
|
||||||
if (dto.relationType() == RelationType.PARENT_OF
|
if (dto.relationType() == RelationType.PARENT_OF
|
||||||
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
@@ -118,8 +119,10 @@ public class RelationshipService {
|
|||||||
.person(person)
|
.person(person)
|
||||||
.relatedPerson(relatedPerson)
|
.relatedPerson(relatedPerson)
|
||||||
.relationType(dto.relationType())
|
.relationType(dto.relationType())
|
||||||
.fromYear(dto.fromYear())
|
.fromDate(dto.fromDate())
|
||||||
.toYear(dto.toYear())
|
.fromDatePrecision(normalizePrecision(dto.fromDatePrecision()))
|
||||||
|
.toDate(dto.toDate())
|
||||||
|
.toDatePrecision(normalizePrecision(dto.toDatePrecision()))
|
||||||
.notes(blankToNull(dto.notes()))
|
.notes(blankToNull(dto.notes()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -173,13 +176,33 @@ public class RelationshipService {
|
|||||||
return date != null ? date.getYear() : null;
|
return date != null ? date.getYear() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
|
||||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
// user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
|
||||||
throw DomainException.badRequest(
|
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
|
||||||
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
LocalDate toDate, DatePrecision toPrecision) {
|
||||||
|
requireDatePrecisionCoherence(fromDate, fromPrecision, "from");
|
||||||
|
requireDatePrecisionCoherence(toDate, toPrecision, "to");
|
||||||
|
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
|
||||||
|
"toDate " + toDate + " is before fromDate " + fromDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||||
|
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date is set but its precision is missing or UNKNOWN");
|
||||||
|
}
|
||||||
|
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date precision " + precision + " is set without a date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
||||||
|
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||||
|
}
|
||||||
|
|
||||||
private static RelationshipDTO toDTO(PersonRelationship r) {
|
private static RelationshipDTO toDTO(PersonRelationship r) {
|
||||||
Person p = r.getPerson();
|
Person p = r.getPerson();
|
||||||
Person rp = r.getRelatedPerson();
|
Person rp = r.getRelatedPerson();
|
||||||
@@ -194,8 +217,10 @@ public class RelationshipService {
|
|||||||
yearOf(rp.getBirthDate()),
|
yearOf(rp.getBirthDate()),
|
||||||
yearOf(rp.getDeathDate()),
|
yearOf(rp.getDeathDate()),
|
||||||
r.getRelationType(),
|
r.getRelationType(),
|
||||||
r.getFromYear(),
|
r.getFromDate(),
|
||||||
r.getToYear(),
|
r.getFromDatePrecision(),
|
||||||
|
r.getToDate(),
|
||||||
|
r.getToDatePrecision(),
|
||||||
r.getNotes());
|
r.getNotes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record CreateRelationshipRequest(
|
|
||||||
@NotNull UUID relatedPersonId,
|
|
||||||
@NotNull RelationType relationType,
|
|
||||||
Integer fromYear,
|
|
||||||
Integer toYear,
|
|
||||||
@Size(max = 2000) String notes
|
|
||||||
) {}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +28,9 @@ public record RelationshipDTO(
|
|||||||
Integer relatedPersonBirthYear,
|
Integer relatedPersonBirthYear,
|
||||||
Integer relatedPersonDeathYear,
|
Integer relatedPersonDeathYear,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
||||||
Integer fromYear,
|
LocalDate fromDate,
|
||||||
Integer toYear,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision,
|
||||||
|
LocalDate toDate,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision,
|
||||||
String notes
|
String notes
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for both creating and updating a relationship — the fields are
|
||||||
|
* identical, so one record serves {@code POST} and {@code PUT} (DRY). A null
|
||||||
|
* {@code *DatePrecision} is normalized to {@code UNKNOWN} by the service; the
|
||||||
|
* service then enforces coherence (date ⇔ non-UNKNOWN precision) and order
|
||||||
|
* (fromDate ≤ toDate).
|
||||||
|
*/
|
||||||
|
public record RelationshipUpsertRequest(
|
||||||
|
@NotNull UUID relatedPersonId,
|
||||||
|
@NotNull RelationType relationType,
|
||||||
|
LocalDate fromDate,
|
||||||
|
DatePrecision fromDatePrecision,
|
||||||
|
LocalDate toDate,
|
||||||
|
DatePrecision toDatePrecision,
|
||||||
|
@Size(max = 2000) String notes
|
||||||
|
) {}
|
||||||
@@ -288,13 +288,11 @@ public class TimelineEventService {
|
|||||||
List<TimelineEntryDTO> result = new ArrayList<>();
|
List<TimelineEntryDTO> result = new ArrayList<>();
|
||||||
for (PersonRelationship r : spouseEdges) {
|
for (PersonRelationship r : spouseEdges) {
|
||||||
if (seen.add(r.getId())) {
|
if (seen.add(r.getId())) {
|
||||||
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
|
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded.
|
||||||
LocalDate eventDate = r.getFromYear() != null
|
// The marriage date is the relationship's from_date at its stored precision
|
||||||
? LocalDate.of(r.getFromYear(), 1, 1)
|
// (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year.
|
||||||
: null;
|
LocalDate eventDate = r.getFromDate();
|
||||||
DatePrecision precision = r.getFromYear() != null
|
DatePrecision precision = r.getFromDatePrecision();
|
||||||
? DatePrecision.YEAR
|
|
||||||
: DatePrecision.UNKNOWN;
|
|
||||||
String title = r.getPerson().getDisplayName()
|
String title = r.getPerson().getDisplayName()
|
||||||
+ " & " + r.getRelatedPerson().getDisplayName();
|
+ " & " + r.getRelatedPerson().getDisplayName();
|
||||||
result.add(new TimelineEntryDTO(
|
result.add(new TimelineEntryDTO(
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- V78: person_relationships.from_year/to_year (integer) → from_date/to_date (date)
|
||||||
|
-- plus NOT NULL precision columns, mirroring persons.{birth,death}_date (V76 / ADR-039).
|
||||||
|
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-044).
|
||||||
|
-- One-way migration: rollback is a targeted pg_restore -t person_relationships from
|
||||||
|
-- the pre-deploy backup (see docs/DEPLOYMENT.md). The column drop is NOT
|
||||||
|
-- rolling-deploy-safe — stop the old JAR before running this migration.
|
||||||
|
|
||||||
|
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
|
||||||
|
-- before any DDL runs. Single-writer family archive, so no race window matters.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year)
|
||||||
|
THEN RAISE EXCEPTION 'V78 aborted: % relationships have from_year > to_year — fix data before migrating',
|
||||||
|
(SELECT COUNT(*) FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year);
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year = 0 OR to_year = 0)
|
||||||
|
THEN RAISE EXCEPTION 'V78 aborted: person_relationships table contains from_year=0 or to_year=0 rows — clean data before migrating';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN from_date date;
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN from_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN to_date date;
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN to_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||||
|
|
||||||
|
UPDATE person_relationships SET from_date = make_date(from_year, 1, 1), from_date_precision = 'YEAR'
|
||||||
|
WHERE from_year IS NOT NULL;
|
||||||
|
UPDATE person_relationships SET to_date = make_date(to_year, 1, 1), to_date_precision = 'YEAR'
|
||||||
|
WHERE to_year IS NOT NULL;
|
||||||
|
|
||||||
|
-- Named constraints: readable Postgres error messages when violated.
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_coherence
|
||||||
|
CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_coherence
|
||||||
|
CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_date_order
|
||||||
|
CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date);
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_precision_values
|
||||||
|
CHECK (from_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_precision_values
|
||||||
|
CHECK (to_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||||
|
|
||||||
|
ALTER TABLE person_relationships DROP COLUMN from_year;
|
||||||
|
ALTER TABLE person_relationships DROP COLUMN to_year;
|
||||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
import org.mockito.InOrder;
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
@@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
RelationshipDTO edge = new RelationshipDTO(
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
UUID.randomUUID(), parentId, childId,
|
UUID.randomUUID(), parentId, childId,
|
||||||
"Parent", null, null, "Child", null, null,
|
"Parent", null, null, "Child", null, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
|
|||||||
new PersonTreeImporter(personService, relationshipService)
|
new PersonTreeImporter(personService, relationshipService)
|
||||||
.load(json.toFile());
|
.load(json.toFile());
|
||||||
|
|
||||||
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
|
||||||
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
||||||
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
||||||
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -98,7 +99,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", 1900, 1980,
|
"Alice Müller", 1900, 1980,
|
||||||
"Bob Müller", 1930, null,
|
"Bob Müller", 1930, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", null, null,
|
"Alice Müller", null, null,
|
||||||
"Bob Müller", null, null,
|
"Bob Müller", null, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies V78: person_relationships.from_year/to_year (integer) become
|
||||||
|
* from_date/to_date (date) + *_date_precision columns, with backfill to
|
||||||
|
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
|
||||||
|
* pre-check that aborts the migration on corrupt year data. Mirrors
|
||||||
|
* {@code PersonBirthDeathMigrationTest} (V76 / ADR-039).
|
||||||
|
*
|
||||||
|
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
|
||||||
|
* database so the staged migrate-to-V77 → seed → migrate-to-latest flow and
|
||||||
|
* the abort cases cannot interfere with each other. Uses a real Postgres
|
||||||
|
* container — H2 does not honour CHECK constraints.
|
||||||
|
*/
|
||||||
|
class RelationshipMigrationTest {
|
||||||
|
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||||
|
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
|
||||||
|
|
||||||
|
private String dbUrl;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startContainer() {
|
||||||
|
POSTGRES.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopContainer() {
|
||||||
|
POSTGRES.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void createFreshDatabase() throws SQLException {
|
||||||
|
String dbName = "mig_v78_" + DB_COUNTER.incrementAndGet();
|
||||||
|
try (Connection conn = DriverManager.getConnection(
|
||||||
|
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE DATABASE " + dbName);
|
||||||
|
}
|
||||||
|
dbUrl = baseUrl(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void precheck_abortsWhenFromYearAfterToYear() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1958, 1923);
|
||||||
|
|
||||||
|
assertThatThrownBy(this::migrateToLatest)
|
||||||
|
.hasMessageContaining("V78 aborted")
|
||||||
|
.hasMessageContaining("from_year > to_year");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void precheck_abortsWhenYearZeroPresent() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "FRIEND", 0, null);
|
||||||
|
|
||||||
|
assertThatThrownBy(this::migrateToLatest)
|
||||||
|
.hasMessageContaining("V78 aborted")
|
||||||
|
.hasMessageContaining("from_year=0 or to_year=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_fromYearAndToYear_becomeYearPrecisionDates() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
RelationDates row = relationDates(a, b, "SPOUSE_OF");
|
||||||
|
assertThat(row.fromDate()).hasToString("1923-01-01");
|
||||||
|
assertThat(row.fromPrecision()).isEqualTo("YEAR");
|
||||||
|
assertThat(row.toDate()).hasToString("1958-01-01");
|
||||||
|
assertThat(row.toPrecision()).isEqualTo("YEAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "FRIEND", null, null);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
RelationDates row = relationDates(a, b, "FRIEND");
|
||||||
|
assertThat(row.fromDate()).isNull();
|
||||||
|
assertThat(row.fromPrecision()).isEqualTo("UNKNOWN");
|
||||||
|
assertThat(row.toDate()).isNull();
|
||||||
|
assertThat(row.toPrecision()).isEqualTo("UNKNOWN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_preservesRowCount() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
UUID c = seedPerson("Gamma");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
seedRelationship(a, c, "FRIEND", null, null);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThat(countWhere("1 = 1")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderCheckConstraint_rejectsToDateBeforeFromDate() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> insertDatedRelationship(
|
||||||
|
a, b, "FRIEND", "1958-01-01", "YEAR", "1923-01-01", "YEAR"))
|
||||||
|
.hasMessageContaining("chk_relationship_date_order");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> insertDatedRelationship(
|
||||||
|
a, b, "FRIEND", "1923-01-01", "UNKNOWN", null, "UNKNOWN"))
|
||||||
|
.hasMessageContaining("chk_relationship_from_coherence");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThat(columnExists("from_year")).isFalse();
|
||||||
|
assertThat(columnExists("to_year")).isFalse();
|
||||||
|
assertThat(columnExists("from_date")).isTrue();
|
||||||
|
assertThat(columnExists("to_date")).isTrue();
|
||||||
|
for (String constraint : new String[]{
|
||||||
|
"chk_relationship_from_coherence",
|
||||||
|
"chk_relationship_to_coherence",
|
||||||
|
"chk_relationship_date_order",
|
||||||
|
"chk_relationship_from_precision_values",
|
||||||
|
"chk_relationship_to_precision_values"}) {
|
||||||
|
assertThat(constraintExists(constraint)).as(constraint).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private static String baseUrl(String dbName) {
|
||||||
|
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateTo(String targetVersion) {
|
||||||
|
flywayBuilder().target(targetVersion).load().migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateToLatest() {
|
||||||
|
flywayBuilder().load().migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
|
||||||
|
return Flyway.configure()
|
||||||
|
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.placeholders(Map.of("grafanaDbPassword", "test-only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID seedPerson(String lastName) throws SQLException {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO persons (id, last_name, person_type, family_member, provisional) "
|
||||||
|
+ "VALUES (?, ?, 'PERSON', false, false)")) {
|
||||||
|
stmt.setObject(1, id);
|
||||||
|
stmt.setString(2, lastName);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedRelationship(UUID personId, UUID relatedId, String type, Integer fromYear, Integer toYear)
|
||||||
|
throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO person_relationships (id, person_id, related_person_id, relation_type, from_year, to_year) "
|
||||||
|
+ "VALUES (gen_random_uuid(), ?, ?, ?, ?, ?)")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
stmt.setObject(4, fromYear);
|
||||||
|
stmt.setObject(5, toYear);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertDatedRelationship(UUID personId, UUID relatedId, String type,
|
||||||
|
String fromDate, String fromPrecision,
|
||||||
|
String toDate, String toPrecision) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO person_relationships "
|
||||||
|
+ "(id, person_id, related_person_id, relation_type, from_date, from_date_precision, to_date, to_date_precision) "
|
||||||
|
+ "VALUES (gen_random_uuid(), ?, ?, ?, CAST(? AS date), ?, CAST(? AS date), ?)")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
stmt.setObject(4, fromDate);
|
||||||
|
stmt.setString(5, fromPrecision);
|
||||||
|
stmt.setObject(6, toDate);
|
||||||
|
stmt.setString(7, toPrecision);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RelationDates(Object fromDate, String fromPrecision, Object toDate, String toPrecision) {}
|
||||||
|
|
||||||
|
private RelationDates relationDates(UUID personId, UUID relatedId, String type) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT from_date, from_date_precision, to_date, to_date_precision "
|
||||||
|
+ "FROM person_relationships WHERE person_id = ? AND related_person_id = ? AND relation_type = ?")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
assertThat(rs.next()).as("relationship exists").isTrue();
|
||||||
|
return new RelationDates(
|
||||||
|
rs.getObject("from_date"),
|
||||||
|
rs.getString("from_date_precision"),
|
||||||
|
rs.getObject("to_date"),
|
||||||
|
rs.getString("to_date_precision"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countWhere(String condition) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM person_relationships WHERE " + condition)) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean columnExists(String columnName) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
+ "WHERE table_schema = 'public' AND table_name = 'person_relationships' AND column_name = ?")) {
|
||||||
|
stmt.setString(1, columnName);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean constraintExists(String constraintName) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
|
||||||
|
stmt.setString(1, constraintName);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Connection connect() throws SQLException {
|
||||||
|
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
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.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
||||||
@@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import;
|
|||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_stores_and_is_readable() {
|
void addRelationship_stores_and_is_readable() {
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||||
|
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null);
|
||||||
|
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(created.id()).isNotNull();
|
assertThat(created.id()).isNotNull();
|
||||||
assertThat(created.personId()).isEqualTo(alice.getId());
|
assertThat(created.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
assertThat(created.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||||
|
assertThat(created.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||||
|
assertThat(created.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
|
|
||||||
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
||||||
assertThat(rels).hasSize(1);
|
assertThat(rels).hasSize(1);
|
||||||
@@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_409_when_duplicate() {
|
void addRelationship_throws_409_when_duplicate() {
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
relationshipService.addRelationship(alice.getId(), dto);
|
relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||||
@@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void addRelationship_throws_409_when_circular_parent() {
|
void addRelationship_throws_409_when_circular_parent() {
|
||||||
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -105,7 +111,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
// Charlie is unrelated to this row.
|
// Charlie is unrelated to this row.
|
||||||
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
||||||
@@ -122,9 +128,9 @@ class RelationshipServiceIntegrationTest {
|
|||||||
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
||||||
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||||
|
|
||||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -135,7 +141,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||||
// alice SPOUSE_OF bob. Bob deletes from his side.
|
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||||
|
|
||||||
relationshipService.deleteRelationship(bob.getId(), created.id());
|
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||||
|
|
||||||
@@ -148,7 +154,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
||||||
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
relationshipService.setFamilyMember(charlie.getId(), false);
|
relationshipService.setFamilyMember(charlie.getId(), false);
|
||||||
|
|
||||||
NetworkDTO before = relationshipService.getFamilyNetwork();
|
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||||
@@ -165,7 +171,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void delete_person_cascades_to_relationships() {
|
void delete_person_cascades_to_relationships() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
UUID relId = created.id();
|
UUID relId = created.id();
|
||||||
assertThat(relationshipRepository.findById(relId)).isPresent();
|
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
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.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -82,7 +84,7 @@ class RelationshipServiceTest {
|
|||||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -98,7 +100,7 @@ class RelationshipServiceTest {
|
|||||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -107,7 +109,7 @@ class RelationshipServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -116,14 +118,42 @@ class RelationshipServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
||||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
|
||||||
|
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.UNKNOWN, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
null, DatePrecision.DAY, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +170,16 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||||
|
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
|
||||||
var result = service.addRelationship(alice.getId(), dto);
|
var result = service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(result.personId()).isEqualTo(alice.getId());
|
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
assertThat(result.fromYear()).isEqualTo(1900);
|
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||||
|
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||||
|
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
assertThat(result.notes()).isEqualTo("first born");
|
assertThat(result.notes()).isEqualTo("first born");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +199,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService).setFamilyMember(alice.getId(), true);
|
verify(personService).setFamilyMember(alice.getId(), true);
|
||||||
@@ -187,7 +220,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
||||||
|
|||||||
@@ -81,12 +81,19 @@ class DerivedEventsAssemblyTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
||||||
|
return makeSpouseEdgeWithDate(a, b,
|
||||||
|
fromYear != null ? LocalDate.of(fromYear, 1, 1) : null,
|
||||||
|
fromYear != null ? DatePrecision.YEAR : DatePrecision.UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersonRelationship makeSpouseEdgeWithDate(Person a, Person b, LocalDate fromDate, DatePrecision precision) {
|
||||||
return PersonRelationship.builder()
|
return PersonRelationship.builder()
|
||||||
.id(UUID.randomUUID())
|
.id(UUID.randomUUID())
|
||||||
.person(a)
|
.person(a)
|
||||||
.relatedPerson(b)
|
.relatedPerson(b)
|
||||||
.relationType(RelationType.SPOUSE_OF)
|
.relationType(RelationType.SPOUSE_OF)
|
||||||
.fromYear(fromYear)
|
.fromDate(fromDate)
|
||||||
|
.fromDatePrecision(precision)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +230,24 @@ class DerivedEventsAssemblyTest {
|
|||||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- REQ-017 (#837): derived Heirat sources SPOUSE_OF.fromDate at its stored precision ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_day_precision_heirat_from_spouse_fromDate() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge = makeSpouseEdgeWithDate(anna, hans, LocalDate.of(1923, 5, 12), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||||
|
|
||||||
|
TimelineEntryDTO heirat = service.assembleDerivedEvents().stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
|
||||||
|
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
||||||
|
assertThat(heirat.precision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
}
|
||||||
|
|
||||||
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user