feat(person): store birth/death as LocalDate + DatePrecision

Entity swap mirroring Document.metaDatePrecision; PersonUpdateDTO takes
date + precision; validateLifeDates (badRequest BIRTH_AFTER_DEATH /
INVALID_DATE_PRECISION) replaces validateYears; preferHumanDate keeps
DAY/MONTH/SEASON hand-entered dates on re-import and refreshes
YEAR/UNKNOWN from the canonical year (ADR-025 extension);
PersonUpsertCommand stays year-shaped. Native queries project
EXTRACT(YEAR ...) so PersonSummaryDTO and PersonNodeDTO stay
year-shaped, null-safe for undated persons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 18:00:05 +02:00
parent 79019ce25f
commit bac38a02b6
10 changed files with 433 additions and 99 deletions

View File

@@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.user.DisplayNameFormatter; import org.raddatz.familienarchiv.user.DisplayNameFormatter;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -49,8 +51,25 @@ public class Person {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String notes; private String notes;
private Integer birthYear; // Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
private Integer deathYear; // the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
private LocalDate birthDate;
@Enumerated(EnumType.STRING)
@Column(name = "birth_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
private LocalDate deathDate;
@Enumerated(EnumType.STRING)
@Column(name = "death_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest). // Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
// Nullable for persons outside the curated family graph. Drives the // Nullable for persons outside the curated family graph. Drives the

View File

@@ -66,7 +66,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """ @Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType, p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional, p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -79,7 +80,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """ @Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType, p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional, p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -89,7 +91,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
ORDER BY p.last_name ASC, p.first_name ASC ORDER BY p.last_name ASC, p.first_name ASC
""", """,
nativeQuery = true) nativeQuery = true)
@@ -100,7 +102,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """ @Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType, p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional, p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -139,7 +142,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """ @Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType, p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional, p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount

View File

@@ -1,5 +1,6 @@
package org.raddatz.familienarchiv.person; package org.raddatz.familienarchiv.person;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -16,6 +17,7 @@ import org.springframework.lang.Nullable;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO; import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO; import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO; import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
@@ -299,13 +301,17 @@ public class PersonService {
} }
private Person fromCanonical(PersonUpsertCommand cmd) { private Person fromCanonical(PersonUpsertCommand cmd) {
DatePrecisionPair birth = yearPair(cmd.birthYear());
DatePrecisionPair death = yearPair(cmd.deathYear());
Person person = personRepository.save(Person.builder() Person person = personRepository.save(Person.builder()
.sourceRef(cmd.sourceRef()) .sourceRef(cmd.sourceRef())
.firstName(blankToNull(cmd.firstName())) .firstName(blankToNull(cmd.firstName()))
.lastName(cmd.lastName()) .lastName(cmd.lastName())
.notes(blankToNull(cmd.notes())) .notes(blankToNull(cmd.notes()))
.birthYear(cmd.birthYear()) .birthDate(birth.date())
.deathYear(cmd.deathYear()) .birthDatePrecision(birth.precision())
.deathDate(death.date())
.deathDatePrecision(death.precision())
.generation(cmd.generation()) .generation(cmd.generation())
.familyMember(cmd.familyMember()) .familyMember(cmd.familyMember())
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType()) .personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
@@ -328,8 +334,14 @@ public class PersonService {
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName())); existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName())); existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes())); existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear())); DatePrecisionPair birth = preferHumanDate(
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear())); existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear());
existing.setBirthDate(birth.date());
existing.setBirthDatePrecision(birth.precision());
DatePrecisionPair death = preferHumanDate(
existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear());
existing.setDeathDate(death.date());
existing.setDeathDatePrecision(death.precision());
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation())); existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) { if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
existing.setPersonType(cmd.personType()); existing.setPersonType(cmd.personType());
@@ -356,6 +368,28 @@ public class PersonService {
return existing != null ? existing : canonical; return existing != null ? existing : canonical;
} }
// Date + precision travel as one value so they can never go out of sync (ADR-039).
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
// preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than
// the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a
// YEAR-precision or absent date is refreshed from the canonical year.
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
Integer canonicalYear) {
boolean handEntered = existingDate != null && existingPrecision != null
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
if (handEntered) {
return new DatePrecisionPair(existingDate, existingPrecision);
}
return yearPair(canonicalYear);
}
private static DatePrecisionPair yearPair(Integer year) {
return year != null
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
}
private static String blankToNull(String s) { private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim(); return (s == null || s.isBlank()) ? null : s.trim();
} }
@@ -375,7 +409,8 @@ public class PersonService {
if (dto.getPersonType() == PersonType.SKIP) { if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation"); throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
} }
validateYears(dto.getBirthYear(), dto.getDeathYear()); validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
Person person = Person.builder() Person person = Person.builder()
.personType(dto.getPersonType()) .personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()) .title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
@@ -383,31 +418,49 @@ public class PersonService {
.lastName(dto.getLastName()) .lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()) .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthYear(dto.getBirthYear()) .birthDate(dto.getBirthDate())
.deathYear(dto.getDeathYear()) .birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
.generation(dto.getGeneration()) .generation(dto.getGeneration())
.build(); .build();
return personRepository.save(person); return personRepository.save(person);
} }
private void validateYears(Integer birthYear, Integer deathYear) { // Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
if (birthYear != null && birthYear <= 0) { // user gets a structured ErrorCode instead of a raw constraint-violation 500.
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein"); private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(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);
} }
if (deathYear != null && deathYear <= 0) { }
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
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 (birthYear != null && deathYear != null && birthYear > deathYear) { if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen"); 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 @Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) { public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) { if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing"); throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
} }
validateYears(dto.getBirthYear(), dto.getDeathYear()); validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
Person person = personRepository.findById(id) Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setPersonType(dto.getPersonType()); person.setPersonType(dto.getPersonType());
@@ -416,8 +469,10 @@ public class PersonService {
person.setLastName(dto.getLastName()); person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()); 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.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear()); person.setBirthDate(dto.getBirthDate());
person.setDeathYear(dto.getDeathYear()); person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
// Form path: a human can clear generation back to null. Unlike the importer // Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim. // which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration()); person.setGeneration(dto.getGeneration());

View File

@@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.PersonType; import org.raddatz.familienarchiv.person.PersonType;
import java.time.LocalDate;
@Data @Data
public class PersonUpdateDTO { public class PersonUpdateDTO {
@NotNull @NotNull
@@ -21,8 +24,10 @@ public class PersonUpdateDTO {
private String alias; private String alias;
@Size(max = 5000) @Size(max = 5000)
private String notes; private String notes;
private Integer birthYear; private LocalDate birthDate;
private Integer deathYear; private DatePrecision birthDatePrecision;
private LocalDate deathDate;
private DatePrecision deathDatePrecision;
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in // Mirror of the persons.generation CHECK constraint (V70). Bounds live in
// PersonGeneration so DB, DTO, and importer all read from one place. // PersonGeneration so DB, DTO, and importer all read from one place.
@Min(PersonGeneration.MIN_GENERATION) @Min(PersonGeneration.MIN_GENERATION)

View File

@@ -96,7 +96,9 @@ public class RelationshipInferenceService {
if (p == null) continue; if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id); List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO( PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.getId(), p.getDisplayName(),
p.getBirthDate() != null ? p.getBirthDate().getYear() : null,
p.getDeathDate() != null ? p.getDeathDate().getYear() : null,
p.getGeneration(), p.isFamilyMember()); p.getGeneration(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size())); out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
} }

View File

@@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -66,7 +67,8 @@ public class RelationshipService {
for (Person p : familyMembers) { for (Person p : familyMembers) {
familyIds.add(p.getId()); familyIds.add(p.getId());
nodes.add(new PersonNodeDTO( nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.getId(), p.getDisplayName(),
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
p.getGeneration(), true)); p.getGeneration(), true));
} }
@@ -155,6 +157,12 @@ public class RelationshipService {
return (s == null || s.isBlank()) ? null : s.trim(); return (s == null || s.isBlank()) ? null : s.trim();
} }
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01).
private static Integer yearOf(LocalDate date) {
return date != null ? date.getYear() : null;
}
private static void validateYears(Integer fromYear, Integer toYear) { private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) { if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest( throw DomainException.badRequest(
@@ -170,11 +178,11 @@ public class RelationshipService {
p.getId(), p.getId(),
rp.getId(), rp.getId(),
p.getDisplayName(), p.getDisplayName(),
p.getBirthYear(), yearOf(p.getBirthDate()),
p.getDeathYear(), yearOf(p.getDeathDate()),
rp.getDisplayName(), rp.getDisplayName(),
rp.getBirthYear(), yearOf(rp.getBirthDate()),
rp.getDeathYear(), yearOf(rp.getDeathDate()),
r.getRelationType(), r.getRelationType(),
r.getFromYear(), r.getFromYear(),
r.getToYear(), r.getToYear(),

View File

@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias; import org.raddatz.familienarchiv.person.PersonNameAlias;
@@ -22,6 +23,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -572,18 +574,53 @@ class PersonControllerTest {
void createPerson_returns200_withAllSixFields() throws Exception { void createPerson_returns200_withAllSixFields() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz") Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build(); .alias("Oma Maria")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
.notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + "\"alias\":\"Oma Maria\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," +
"\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," +
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}")) "\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria")) .andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria")) .andExpect(jsonPath("$.alias").value("Oma Maria"))
.andExpect(jsonPath("$.birthYear").value(1901)); .andExpect(jsonPath("$.birthDate").value("1901-03-14"))
.andExpect(jsonPath("$.birthDatePrecision").value("DAY"));
}
// ─── #773: malformed date payloads return structured 400s, not Jackson traces ──
// Jackson rejects unknown enum values by default. Verified 2026-06-12: the only
// DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper
// (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override.
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
} }
// ─── Phase 1.2: @Size constraints ───────────────────────────────────────── // ─── Phase 1.2: @Size constraints ─────────────────────────────────────────

View File

@@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -97,24 +99,120 @@ class PersonImportUpsertTest {
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert"); assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
} }
// ─── life dates (ADR-025 extension via preferHumanDate, #773) ─────────────
@Test @Test
void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() { void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() {
// Existing has a human-set birthYear and a blank deathYear. // A human entered the exact birthday in-app; the spreadsheet only knows a year.
Person existing = Person.builder() Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram") .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.lastName("Cram").birthYear(1890).deathYear(null).build(); .birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing)); when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder() PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram") .sourceRef("clara-cram").lastName("Cram")
.birthYear(1888).deathYear(1965) .birthYear(1888)
.personType(PersonType.PERSON).provisional(false).build(); .personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd); Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14));
assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void upsertBySourceRef_preservesMonthPrecisionDate_onReimport() {
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.deathDate(LocalDate.of(1944, 11, 1)).deathDatePrecision(DatePrecision.MONTH).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1945)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.MONTH);
}
@Test
void upsertBySourceRef_refreshesYearPrecisionDate_whenSpreadsheetYearChanges() {
// YEAR precision means "the importer's year" — a corrected spreadsheet year wins.
Person yearOnly = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1890, 1, 1)).birthDatePrecision(DatePrecision.YEAR).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(yearOnly));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1888)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1888, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_fillsEmptyDateAtYearPrecision_onReimport() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_keepsDatesEmpty_whenSpreadsheetHasNoYear() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_translatesYearToDate_onFirstImport() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1890).deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
} }
@Test @Test

View File

@@ -18,6 +18,9 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -910,4 +913,61 @@ class PersonRepositoryTest {
.setParameter(1, blockId).getSingleResult(); .setParameter(1, blockId).getSingleResult();
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram"); assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
} }
// ─── #773: PersonSummaryDTO year projection from birth_date/death_date ──────
@Test
void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() {
personRepository.save(Person.builder()
.firstName("Maria").lastName("Datiert")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.build());
personRepository.save(Person.builder()
.firstName("Nora").lastName("Undatiert")
.build());
entityManager.flush();
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
PersonSummaryDTO dated = all.stream()
.filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(dated.getBirthYear()).isEqualTo(1901);
assertThat(dated.getDeathYear()).isNull();
PersonSummaryDTO undated = all.stream()
.filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(undated.getBirthYear()).isNull();
assertThat(undated.getDeathYear()).isNull();
}
@Test
void searchWithDocumentCount_groupByPath_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Herbert").lastName("Gruppiert")
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Gruppiert");
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1899);
assertThat(found.get(0).getDeathYear()).isEqualTo(1972);
}
@Test
void findByFilter_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Filtriert").lastName("Person")
.birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.findByFilter(
null, null, null, null, false, "Filtriert", 10, 0);
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1920);
assertThat(found.get(0).getDeathYear()).isNull();
}
} }

View File

@@ -8,7 +8,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO; import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO; import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO; import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias; import org.raddatz.familienarchiv.person.PersonNameAlias;
import org.raddatz.familienarchiv.person.PersonNameAliasType; import org.raddatz.familienarchiv.person.PersonNameAliasType;
@@ -17,6 +19,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
import org.raddatz.familienarchiv.person.PersonRepository; import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -241,27 +244,49 @@ class PersonServiceTest {
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria"); dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes"); dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1975, 11, 2)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setNotes("Some notes");
Person result = personService.createPerson(dto); Person result = personService.createPerson(dto);
assertThat(result.getFirstName()).isEqualTo("Maria"); assertThat(result.getFirstName()).isEqualTo("Maria");
assertThat(result.getLastName()).isEqualTo("Raddatz"); assertThat(result.getLastName()).isEqualTo("Raddatz");
assertThat(result.getAlias()).isEqualTo("Oma Maria"); assertThat(result.getAlias()).isEqualTo("Oma Maria");
assertThat(result.getBirthYear()).isEqualTo(1901); assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
assertThat(result.getDeathYear()).isEqualTo(1975); assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1975, 11, 2));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getNotes()).isEqualTo("Some notes"); assertThat(result.getNotes()).isEqualTo("Some notes");
} }
@Test @Test
void createPerson_dto_yearValidationFires_whenBirthYearNegative() { void createPerson_dto_rejectsDateWithUnknownPrecision() {
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1); dto.setFirstName("Anna"); dto.setLastName("Test");
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.UNKNOWN);
assertThatThrownBy(() -> personService.createPerson(dto)) assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(DomainException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) .satisfies(e -> {
.isEqualTo(400); assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
}
@Test
void createPerson_dto_treatsNullPrecisionWithNullDateAsUnknown() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.PERSON);
Person result = personService.createPerson(dto);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
} }
@Test @Test
@@ -600,114 +625,135 @@ class PersonServiceTest {
assertThat(result.getNotes()).isNull(); assertThat(result.getNotes()).isNull();
} }
// ─── updatePerson (birth/death years) ──────────────────────────────────── // ─── updatePerson (birth/death dates) ────────────────────────────────────
@Test @Test
void updatePerson_persistsBirthAndDeathYear() { void updatePerson_persistsBirthAndDeathDateWithPrecision() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965); dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1965, 6, 12)); dto.setDeathDatePrecision(DatePrecision.DAY);
Person result = personService.updatePerson(id, dto); Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1890); assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getDeathYear()).isEqualTo(1965); assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
} }
@Test @Test
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() { void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950); dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1970, 5, 1)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1950, 5, 1)); dto.setDeathDatePrecision(DatePrecision.DAY);
assertThatThrownBy(() -> personService.updatePerson(id, dto)) assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(DomainException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) .satisfies(e -> {
.isEqualTo(400); assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
} }
@Test @Test
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() { void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() {
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw // Known limitation (#773): DAY-precision birth late in the death's YEAR-precision year
// compares against the year's backfilled Jan 1st and is rejected. The error message
// carries the workaround hint via the BIRTH_AFTER_DEATH i18n key.
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 11, 15)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1901, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
}
@Test
void updatePerson_doesNotThrow_whenBirthDateSetButDeathDateNull() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null); dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
Person result = personService.updatePerson(id, dto); Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1890); assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getDeathYear()).isNull(); assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
} }
@Test @Test
void updatePerson_allowsSameYear() { void updatePerson_allowsEqualBirthAndDeathDate() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900); dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1900, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1900, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
Person result = personService.updatePerson(id, dto); Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1900); assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getDeathYear()).isEqualTo(1900); assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1));
} }
// ─── Phase 1.3: Year range bounds (> 0) ────────────────────────────────── // ─── Date/precision coherence (V76 CHECK constraint mirror) ─────────────
@Test @Test
void updatePerson_throwsBadRequest_whenBirthYearIsZero() { void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0); dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setDeathDate(LocalDate.of(1944, 11, 2)); dto.setDeathDatePrecision(DatePrecision.UNKNOWN);
assertThatThrownBy(() -> personService.updatePerson(id, dto)) assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(DomainException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) .satisfies(e -> {
.isEqualTo(400); assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
} }
@Test @Test
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() { void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5); dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 3, 14));
assertThatThrownBy(() -> personService.updatePerson(id, dto)) assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(DomainException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) .extracting(e -> ((DomainException) e).getCode())
.isEqualTo(400); .isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
} }
@Test @Test
void updatePerson_throwsBadRequest_whenDeathYearIsZero() { void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO(); PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0); dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDatePrecision(DatePrecision.DAY);
assertThatThrownBy(() -> personService.updatePerson(id, dto)) assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(DomainException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) .extracting(e -> ((DomainException) e).getCode())
.isEqualTo(400); .isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
} }
// ─── findCorrespondents ────────────────────────────────────────────────── // ─── findCorrespondents ──────────────────────────────────────────────────