feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s

Closes #837

Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free.

Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`).

## What's in it
- **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped.
- **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint.
- New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6).
- `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision.
- Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated.
- Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019.

## Requirements
All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`.

## Test plan
- **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds.
- **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean.

## Notes for reviewers
- **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`.
- `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned).
- **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #841
This commit was merged in pull request #841.
This commit is contained in:
2026-06-14 21:17:36 +02:00
parent 6dae4fe428
commit 8558567688
59 changed files with 2196 additions and 580 deletions

View File

@@ -0,0 +1,42 @@
package org.raddatz.familienarchiv.document;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import java.time.LocalDate;
/**
* Cross-field validation and normalization shared by every domain that stores a
* {@link LocalDate} + {@link DatePrecision} pair — a person's life dates (ADR-039 / V76)
* and a relationship's from/to dates (ADR-044 / V78). Kept out of {@link DatePrecision}
* itself because that enum is a frozen contract mirror of the import normalizer (ADR-025)
* and must carry no behaviour.
*/
public final class DatePrecisionValidation {
private DatePrecisionValidation() {}
/**
* Enforces the date ⇔ precision coherence the V76/V78 CHECK constraints also enforce:
* a date requires a non-{@code UNKNOWN} precision, and a non-{@code UNKNOWN} precision
* requires a date. Validated in-service so the caller gets a structured 400 instead of
* the database constraint's raw 500.
*
* @param side human-readable field label woven into the error message ("birth", "from", …)
*/
public static void requireCoherence(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");
}
}
/** A null precision means "no precision recorded" → {@link DatePrecision#UNKNOWN}. */
public static DatePrecision normalize(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
}

View File

@@ -122,6 +122,8 @@ public enum ErrorCode {
CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP,
/** A relationship's toDate is before its fromDate. 400 */
INVALID_RELATIONSHIP_DATES,
// --- Geschichten (Stories) ---
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */

View File

@@ -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

View File

@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
@@ -448,41 +449,28 @@ public class PersonService {
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthDate(dto.getBirthDate())
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
.generation(dto.getGeneration())
.build();
return personRepository.save(person);
}
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
// user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence
// is shared with the relationship domain (DatePrecisionValidation); only the order check
// (and its BIRTH_AFTER_DEATH code) is life-date specific.
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"Birth date " + birthDate + " is after death date " + deathDate);
}
}
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;
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
@@ -499,9 +487,9 @@ public class PersonService {
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
// Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration());

View File

@@ -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;

View File

@@ -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,11 +63,20 @@ 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));
}
@PutMapping("/api/persons/{id}/relationships/{relId}")
@RequirePermission(Permission.WRITE_ALL)
public RelationshipDTO updateRelationship(
@PathVariable UUID id,
@PathVariable UUID relId,
@Valid @RequestBody RelationshipUpsertRequest dto) {
return relationshipService.updateRelationship(id, relId, dto);
}
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.person.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
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,65 +98,129 @@ public class RelationshipService {
}
@Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
requireNotSelf(personId, dto.relatedPersonId());
Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId());
validateYears(dto.fromYear(), dto.toYear());
if (dto.relationType() == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
}
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
PersonRelationship rel = PersonRelationship.builder()
.person(person)
.relatedPerson(relatedPerson)
.relationType(dto.relationType())
.fromYear(dto.fromYear())
.toYear(dto.toYear())
.fromDate(dto.fromDate())
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
.toDate(dto.toDate())
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
.notes(blankToNull(dto.notes()))
.build();
PersonRelationship saved;
try {
// saveAndFlush so the unique_rel constraint violates synchronously and is
// caught here, not at commit time outside the @Transactional boundary.
saved = relationshipRepository.saveAndFlush(rel);
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
}
// Family-graph edges imply both endpoints are family members. Idempotent: the
// setter is a no-op when the person is already flagged, so re-imports stay clean.
if (FAMILY_RELATION_TYPES.contains(dto.relationType())) {
personService.setFamilyMember(person.getId(), true);
personService.setFamilyMember(relatedPerson.getId(), true);
}
PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
return toDTO(saved);
}
@Transactional
public RelationshipDTO updateRelationship(UUID personId, UUID relId, RelationshipUpsertRequest dto) {
PersonRelationship rel = loadOwnedRelationship(personId, relId);
// The other party from {personId}'s viewpoint cannot be {personId} itself.
requireNotSelf(personId, dto.relatedPersonId());
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
// Preserve the directed orientation: {personId} keeps whichever role (subject or
// object) it already holds on the row, and the edited "related person" takes the
// other role. So a PARENT_OF edge stays parent→child whether the curator edits it
// from the parent's page or the child's.
boolean viewpointIsSubject = personId.equals(rel.getPerson().getId());
Person viewpoint = viewpointIsSubject ? rel.getPerson() : rel.getRelatedPerson();
Person other = personService.getById(dto.relatedPersonId());
Person newSubject = viewpointIsSubject ? viewpoint : other;
Person newObject = viewpointIsSubject ? other : viewpoint;
requireNoReverseParent(newSubject.getId(), newObject.getId(), dto.relationType());
rel.setPerson(newSubject);
rel.setRelatedPerson(newObject);
rel.setRelationType(dto.relationType());
rel.setFromDate(dto.fromDate());
rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
rel.setToDate(dto.toDate());
rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
rel.setNotes(blankToNull(dto.notes()));
PersonRelationship saved = persistOrConflict(rel, newSubject.getId(), newObject.getId(), dto.relationType());
flagFamilyMembership(dto.relationType(), newSubject.getId(), newObject.getId());
return toDTO(saved);
}
// --- shared create/update invariants ---------------------------------------------
// A person cannot be related to themselves, from either viewpoint.
private static void requireNotSelf(UUID viewpointId, UUID relatedPersonId) {
if (viewpointId.equals(relatedPersonId)) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
}
// A PARENT_OF edge must not already have its mirror (child PARENT_OF parent) stored —
// that would be a cycle. No-op for every other relation type.
private void requireNoReverseParent(UUID subjectId, UUID objectId, RelationType type) {
if (type == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
objectId, subjectId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + subjectId + " and " + objectId);
}
}
// saveAndFlush so the unique_rel constraint violates synchronously and is caught here,
// inside the @Transactional boundary, not at commit time as a raw 500.
private PersonRelationship persistOrConflict(PersonRelationship rel, UUID subjectId, UUID objectId, RelationType type) {
try {
return relationshipRepository.saveAndFlush(rel);
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")");
}
}
// Family-graph edges imply both endpoints are family members. Idempotent (the setter is
// a no-op when already flagged, so re-imports stay clean) and additive — an edit never
// auto-unflags.
private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) {
if (FAMILY_RELATION_TYPES.contains(type)) {
personService.setFamilyMember(subjectId, true);
personService.setFamilyMember(objectId, true);
}
}
@Transactional
public void deleteRelationship(UUID personId, UUID relId) {
PersonRelationship rel = loadOwnedRelationship(personId, relId);
relationshipRepository.delete(rel);
}
// Loads the row and verifies {personId} is one of its endpoints. A mismatch is 404
// (not 403): an anti-enumeration choice so a curator cannot probe relationship ids
// belonging to people they cannot see. Shared by update + delete for consistency.
private PersonRelationship loadOwnedRelationship(UUID personId, UUID relId) {
PersonRelationship rel = relationshipRepository.findById(relId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
throw DomainException.forbidden(
throw DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND,
"Relationship " + relId + " does not belong to person " + personId);
}
relationshipRepository.delete(rel);
return rel;
}
@Transactional
@@ -173,10 +239,17 @@ 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.
// Coherence is shared with the person domain (DatePrecisionValidation); only the order
// check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
LocalDate toDate, DatePrecision toPrecision) {
DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
"toDate " + toDate + " is before fromDate " + fromDate);
}
}
@@ -194,8 +267,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());
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -290,13 +290,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(

View File

@@ -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;

View File

@@ -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()));

View File

@@ -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);

View File

@@ -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;
@@ -25,6 +26,8 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -98,7 +101,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 +142,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())
@@ -158,4 +161,51 @@ class RelationshipControllerTest {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
.andExpect(status().isNoContent());
}
// ─── PUT /api/persons/{id}/relationships/{relId} ──────────────────────────
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
UUID relId = UUID.randomUUID();
RelationshipDTO updated = new RelationshipDTO(
relId, PERSON_ID, OTHER_ID,
"Alice Müller", null, null,
"Bob Müller", null, null,
RelationType.SPOUSE_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
when(relationshipService.updateRelationship(any(), any(), any())).thenReturn(updated);
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.relationType").value("SPOUSE_OF"));
}
@Test
void updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service() throws Exception {
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
.andExpect(status().isUnauthorized());
verify(relationshipService, never()).updateRelationship(any(), any(), any());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void updateRelationship_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void updateRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest());
}
}

View File

@@ -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());
}
}

View File

@@ -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")
@@ -103,28 +109,58 @@ class RelationshipServiceIntegrationTest {
}
@Test
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
void deleteRelationship_throws_404_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.
// Charlie is unrelated to this row. Ownership mismatch is 404, not 403, so a
// curator cannot enumerate relationship ids belonging to people they can't see
// (anti-enumeration; aligned with the PUT endpoint — ADR-044).
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
// The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent();
}
@Test
void updateRelationship_persists_new_type_dates_and_notes() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null));
RelationshipDTO updated = relationshipService.updateRelationship(alice.getId(), created.id(),
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day"));
assertThat(updated.id()).isEqualTo(created.id());
assertThat(updated.relationType()).isEqualTo(RelationType.SPOUSE_OF);
assertThat(updated.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
assertThat(updated.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(updated.notes()).isEqualTo("wedding day");
}
@Test
void updateRelationship_throws_404_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
assertThatThrownBy(() -> relationshipService.updateRelationship(charlie.getId(), created.id(),
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null)))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
@Test
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// 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 +171,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 +184,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 +201,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();

View File

@@ -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;
@@ -59,9 +61,9 @@ class RelationshipServiceTest {
charlie = person("Charlie");
}
// --- Nora blocker 1 ---
// --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
@Test
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
@@ -69,7 +71,7 @@ class RelationshipServiceTest {
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
verify(relationshipRepository, never()).delete(any());
}
@@ -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());
@@ -216,6 +249,131 @@ class RelationshipServiceTest {
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
// --- updateRelationship (REQ-004/006/007/008/009/010/013) ---
@Test
void updateRelationship_throws_NOT_FOUND_when_relId_unknown() {
UUID relId = UUID.randomUUID();
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(charlie.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_VALIDATION_ERROR_on_self_relation() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
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.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_DUPLICATE_when_db_constraint_violated() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void updateRelationship_updates_fields_and_returns_dto() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day");
var result = service.updateRelationship(alice.getId(), relId, dto);
assertThat(result.relationType()).isEqualTo(RelationType.SPOUSE_OF);
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.notes()).isEqualTo("wedding day");
}
@Test
void updateRelationship_marks_both_endpoints_family_when_updated_to_family_type() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SIBLING_OF, null, null, null, null, null);
service.updateRelationship(alice.getId(), relId, dto);
verify(personService).setFamilyMember(alice.getId(), true);
verify(personService).setFamilyMember(bob.getId(), true);
}
@Test
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
@@ -260,11 +418,15 @@ class RelationshipServiceTest {
}
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
return relOf(parent, child, RelationType.PARENT_OF, id);
}
private static PersonRelationship relOf(Person subject, Person object, RelationType type, UUID id) {
return PersonRelationship.builder()
.id(id)
.person(parent)
.relatedPerson(child)
.relationType(RelationType.PARENT_OF)
.person(subject)
.relatedPerson(object)
.relationType(type)
.createdAt(Instant.now())
.build();
}

View File

@@ -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