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.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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RelationshipDTO> addRelationship(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody CreateRelationshipRequest dto) {
|
||||
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(relationshipService.addRelationship(id, dto));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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
|
||||
) {}
|
||||
|
||||
@@ -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<>();
|
||||
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(
|
||||
|
||||
@@ -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.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()));
|
||||
|
||||
@@ -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<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
||||
ArgumentCaptor<RelationshipUpsertRequest> 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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.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<RelationshipDTO> 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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user