diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java index df402050..45faade9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java @@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType; import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.relationship.RelationType; 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 java.io.File; @@ -126,7 +126,7 @@ public class PersonTreeImporter { private boolean addRelationshipIdempotently(UUID person, UUID related, String type) { try { 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; } catch (DomainException e) { if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/PersonRelationship.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/PersonRelationship.java index 889956ce..16497378 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/PersonRelationship.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/PersonRelationship.java @@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.person.Person; import java.time.Instant; +import java.time.LocalDate; import java.util.UUID; @Entity @@ -39,11 +41,25 @@ public class PersonRelationship { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private RelationType relationType; - @Column(name = "from_year") - private Integer fromYear; + // Start/end of the relationship (wedding, employment start, …). The date column + // 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") - private Integer toYear; + @Enumerated(EnumType.STRING) + @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) private String notes; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java index 9638fc9a..2b2de737 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java @@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; 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.InferredRelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; @@ -63,7 +63,7 @@ public class RelationshipController { @RequirePermission(Permission.WRITE_ALL) public ResponseEntity addRelationship( @PathVariable UUID id, - @Valid @RequestBody CreateRelationshipRequest dto) { + @Valid @RequestBody RelationshipUpsertRequest dto) { return ResponseEntity.status(HttpStatus.CREATED) .body(relationshipService.addRelationship(id, dto)); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java index ee237617..0265bfe1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java @@ -1,10 +1,11 @@ package org.raddatz.familienarchiv.person.relationship; import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; 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.InferredRelationshipWithPersonDTO; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; @@ -96,7 +97,7 @@ public class RelationshipService { } @Transactional - public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) { + public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) { if (personId.equals(dto.relatedPersonId())) { throw DomainException.badRequest( ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves"); @@ -104,7 +105,7 @@ public class RelationshipService { Person person = personService.getById(personId); 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 && relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( @@ -118,8 +119,10 @@ public class RelationshipService { .person(person) .relatedPerson(relatedPerson) .relationType(dto.relationType()) - .fromYear(dto.fromYear()) - .toYear(dto.toYear()) + .fromDate(dto.fromDate()) + .fromDatePrecision(normalizePrecision(dto.fromDatePrecision())) + .toDate(dto.toDate()) + .toDatePrecision(normalizePrecision(dto.toDatePrecision())) .notes(blankToNull(dto.notes())) .build(); @@ -173,13 +176,33 @@ public class RelationshipService { return date != null ? date.getYear() : null; } - private static void validateYears(Integer fromYear, Integer toYear) { - if (fromYear != null && toYear != null && toYear < fromYear) { - throw DomainException.badRequest( - ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear"); + // Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the + // user gets a structured 400 instead of the DB CHECK constraint's 500, then order. + private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision, + 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) { Person p = r.getPerson(); Person rp = r.getRelatedPerson(); @@ -194,8 +217,10 @@ public class RelationshipService { yearOf(rp.getBirthDate()), yearOf(rp.getDeathDate()), r.getRelationType(), - r.getFromYear(), - r.getToYear(), + r.getFromDate(), + r.getFromDatePrecision(), + r.getToDate(), + r.getToDatePrecision(), r.getNotes()); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/CreateRelationshipRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/CreateRelationshipRequest.java deleted file mode 100644 index 2495c361..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/CreateRelationshipRequest.java +++ /dev/null @@ -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 -) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java index 01d4b3d6..cb1cdbc0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java @@ -1,8 +1,10 @@ package org.raddatz.familienarchiv.person.relationship.dto; import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.person.relationship.RelationType; +import java.time.LocalDate; import java.util.UUID; /** @@ -26,7 +28,9 @@ public record RelationshipDTO( Integer relatedPersonBirthYear, Integer relatedPersonDeathYear, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType, - Integer fromYear, - Integer toYear, + LocalDate fromDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision, + LocalDate toDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision, String notes ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipUpsertRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipUpsertRequest.java new file mode 100644 index 00000000..db96fb3a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipUpsertRequest.java @@ -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 +) {} 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 75803bb0..a66ef3c5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -288,13 +288,11 @@ public class TimelineEventService { 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; + // JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded. + // The marriage date is the relationship's from_date at its stored precision + // (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year. + LocalDate eventDate = r.getFromDate(); + DatePrecision precision = r.getFromDatePrecision(); String title = r.getPerson().getDisplayName() + " & " + r.getRelatedPerson().getDisplayName(); result.add(new TimelineEntryDTO( diff --git a/backend/src/main/resources/db/migration/V78__relationship_years_to_localdate.sql b/backend/src/main/resources/db/migration/V78__relationship_years_to_localdate.sql new file mode 100644 index 00000000..ab80ae59 --- /dev/null +++ b/backend/src/main/resources/db/migration/V78__relationship_years_to_localdate.sql @@ -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; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java index 56a868bb..c1aca067 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationshipService; @@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest { RelationshipDTO edge = new RelationshipDTO( UUID.randomUUID(), parentId, childId, "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()) .thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge))); when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of())); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java index d43191bb..a1ff6ea6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java @@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.relationship.RelationType; 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.Path; @@ -76,7 +76,7 @@ class PersonTreeImporterTest { new PersonTreeImporter(personService, relationshipService) .load(json.toFile()); - ArgumentCaptor captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class); verify(relationshipService).addRelationship(eq(idA), captor.capture()); assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB); assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java index 91f35eea..8bf9b92f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.person.relationship; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; @@ -98,7 +99,7 @@ class RelationshipControllerTest { UUID.randomUUID(), PERSON_ID, OTHER_ID, "Alice Müller", 1900, 1980, "Bob Müller", 1930, null, - RelationType.PARENT_OF, null, null, null); + RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null); when(relationshipService.getFamilyNetwork()) .thenReturn(new NetworkDTO(List.of(node), List.of(edge))); @@ -139,7 +140,7 @@ class RelationshipControllerTest { UUID.randomUUID(), PERSON_ID, OTHER_ID, "Alice 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); mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipMigrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipMigrationTest.java new file mode 100644 index 00000000..3a1a1f3a --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipMigrationTest.java @@ -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). + * + *

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()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java index acbb3825..5f8391b2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java @@ -4,10 +4,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; 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.RelationshipDTO; import org.raddatz.familienarchiv.person.PersonNameAliasRepository; @@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import; import jakarta.persistence.EntityManager; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest { @Test 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); assertThat(created.id()).isNotNull(); assertThat(created.personId()).isEqualTo(alice.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 rels = relationshipService.getRelationships(alice.getId()); assertThat(rels).hasSize(1); @@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest { @Test 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); assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto)) @@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest { void addRelationship_throws_409_when_circular_parent() { // alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected. 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)) .isInstanceOf(DomainException.class) .extracting("code") @@ -105,7 +111,7 @@ class RelationshipServiceIntegrationTest { @Test void deleteRelationship_throws_403_when_rel_belongs_to_different_person() { 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. 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) // and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF. 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)) .isInstanceOf(DomainException.class) .extracting("code") @@ -135,7 +141,7 @@ class RelationshipServiceIntegrationTest { void deleteRelationship_succeeds_for_symmetric_type_from_either_side() { // alice SPOUSE_OF bob. Bob deletes from his side. 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()); @@ -148,7 +154,7 @@ class RelationshipServiceIntegrationTest { // 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. 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); NetworkDTO before = relationshipService.getFamilyNetwork(); @@ -165,7 +171,7 @@ class RelationshipServiceIntegrationTest { @Test void delete_person_cascades_to_relationships() { 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(); assertThat(relationshipRepository.findById(relId)).isPresent(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java index d8d41a9d..6123ee30 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java @@ -6,16 +6,18 @@ 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.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; 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.springframework.dao.DataIntegrityViolationException; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import java.time.Instant; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -82,7 +84,7 @@ class RelationshipServiceTest { when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( 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)) .isInstanceOf(DomainException.class) .extracting("code") @@ -98,7 +100,7 @@ class RelationshipServiceTest { bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); 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)) .isInstanceOf(DomainException.class) .extracting("code") @@ -107,7 +109,7 @@ class RelationshipServiceTest { @Test 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)) .isInstanceOf(DomainException.class) .extracting("code") @@ -116,14 +118,42 @@ class RelationshipServiceTest { } @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(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)) .isInstanceOf(DomainException.class) .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()); } @@ -140,13 +170,16 @@ class RelationshipServiceTest { 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); assertThat(result.personId()).isEqualTo(alice.getId()); assertThat(result.relatedPersonId()).isEqualTo(bob.getId()); 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"); } @@ -166,7 +199,7 @@ class RelationshipServiceTest { 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); verify(personService).setFamilyMember(alice.getId(), true); @@ -187,7 +220,7 @@ class RelationshipServiceTest { 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); verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java index ddef7bd6..6e638dbc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java @@ -81,12 +81,19 @@ class DerivedEventsAssemblyTest { } 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() .id(UUID.randomUUID()) .person(a) .relatedPerson(b) .relationType(RelationType.SPOUSE_OF) - .fromYear(fromYear) + .fromDate(fromDate) + .fromDatePrecision(precision) .build(); } @@ -223,6 +230,24 @@ class DerivedEventsAssemblyTest { 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) --- @Test