From 92a2feba1e9f65306d440342f0c0db14f3c5882f Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:00:05 +0200 Subject: [PATCH] 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 --- .../raddatz/familienarchiv/person/Person.java | 23 ++- .../person/PersonRepository.java | 14 +- .../familienarchiv/person/PersonService.java | 89 ++++++++-- .../person/PersonUpdateDTO.java | 9 +- .../RelationshipInferenceService.java | 4 +- .../relationship/RelationshipService.java | 18 +- .../person/PersonControllerTest.java | 43 ++++- .../person/PersonImportUpsertTest.java | 116 ++++++++++++- .../person/PersonRepositoryTest.java | 60 +++++++ .../person/PersonServiceTest.java | 156 ++++++++++++------ 10 files changed, 433 insertions(+), 99 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java index 84058e2f..74783718 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java @@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.user.DisplayNameFormatter; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -49,8 +51,25 @@ public class Person { @Column(columnDefinition = "TEXT") private String notes; - private Integer birthYear; - private Integer deathYear; + // Most precise birth/death date known. Precision mirrors Document.metaDatePrecision: + // 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). // Nullable for persons outside the curated family graph. Drives the diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 0afc9e66..9e14e48e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -66,7 +66,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, 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, (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 @@ -79,7 +80,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, 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, (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 @@ -89,7 +91,7 @@ public interface PersonRepository extends JpaRepository { OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(p.alias) 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 """, nativeQuery = true) @@ -100,7 +102,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, 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, (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 @@ -139,7 +142,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, 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, (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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index d195d0bb..0608639b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.person; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -16,6 +17,7 @@ import org.springframework.lang.Nullable; 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.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; @@ -299,13 +301,17 @@ public class PersonService { } private Person fromCanonical(PersonUpsertCommand cmd) { + DatePrecisionPair birth = yearPair(cmd.birthYear()); + DatePrecisionPair death = yearPair(cmd.deathYear()); Person person = personRepository.save(Person.builder() .sourceRef(cmd.sourceRef()) .firstName(blankToNull(cmd.firstName())) .lastName(cmd.lastName()) .notes(blankToNull(cmd.notes())) - .birthYear(cmd.birthYear()) - .deathYear(cmd.deathYear()) + .birthDate(birth.date()) + .birthDatePrecision(birth.precision()) + .deathDate(death.date()) + .deathDatePrecision(death.precision()) .generation(cmd.generation()) .familyMember(cmd.familyMember()) .personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType()) @@ -328,8 +334,14 @@ public class PersonService { existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName())); existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName())); existing.setNotes(preferHuman(existing.getNotes(), cmd.notes())); - existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear())); - existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear())); + DatePrecisionPair birth = preferHumanDate( + 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())); if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) { existing.setPersonType(cmd.personType()); @@ -356,6 +368,28 @@ public class PersonService { 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) { return (s == null || s.isBlank()) ? null : s.trim(); } @@ -375,7 +409,8 @@ public class PersonService { if (dto.getPersonType() == PersonType.SKIP) { 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() .personType(dto.getPersonType()) .title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()) @@ -383,31 +418,49 @@ public class PersonService { .lastName(dto.getLastName()) .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()) - .birthYear(dto.getBirthYear()) - .deathYear(dto.getDeathYear()) + .birthDate(dto.getBirthDate()) + .birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())) + .deathDate(dto.getDeathDate()) + .deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())) .generation(dto.getGeneration()) .build(); return personRepository.save(person); } - private void validateYears(Integer birthYear, Integer deathYear) { - if (birthYear != null && birthYear <= 0) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein"); + // 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. + 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) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen"); + 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) { 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) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); person.setPersonType(dto.getPersonType()); @@ -416,8 +469,10 @@ public class PersonService { person.setLastName(dto.getLastName()); 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.setBirthYear(dto.getBirthYear()); - person.setDeathYear(dto.getDeathYear()); + person.setBirthDate(dto.getBirthDate()); + 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 // which routes through preferHuman, we write the DTO value verbatim. person.setGeneration(dto.getGeneration()); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java index a3cd9aca..483cbef9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java @@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.person.PersonType; +import java.time.LocalDate; + @Data public class PersonUpdateDTO { @NotNull @@ -21,8 +24,10 @@ public class PersonUpdateDTO { private String alias; @Size(max = 5000) private String notes; - private Integer birthYear; - private Integer deathYear; + private LocalDate birthDate; + private DatePrecision birthDatePrecision; + private LocalDate deathDate; + private DatePrecision deathDatePrecision; // Mirror of the persons.generation CHECK constraint (V70). Bounds live in // PersonGeneration so DB, DTO, and importer all read from one place. @Min(PersonGeneration.MIN_GENERATION) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java index c93e0208..96d7fa50 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java @@ -96,7 +96,9 @@ public class RelationshipInferenceService { if (p == null) continue; List path = shortestPaths.get(id); 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()); out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size())); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java index 9c8096ab..e764da04 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java @@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -66,7 +67,8 @@ public class RelationshipService { for (Person p : familyMembers) { familyIds.add(p.getId()); 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)); } @@ -155,6 +157,12 @@ public class RelationshipService { 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) { if (fromYear != null && toYear != null && toYear < fromYear) { throw DomainException.badRequest( @@ -170,11 +178,11 @@ public class RelationshipService { p.getId(), rp.getId(), p.getDisplayName(), - p.getBirthYear(), - p.getDeathYear(), + yearOf(p.getBirthDate()), + yearOf(p.getDeathDate()), rp.getDisplayName(), - rp.getBirthYear(), - rp.getDeathYear(), + yearOf(rp.getBirthDate()), + yearOf(rp.getDeathDate()), r.getRelationType(), r.getFromYear(), r.getToYear(), diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java index 783924c0..60231c94 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.person.Person; 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.web.servlet.MockMvc; +import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.UUID; @@ -572,18 +574,53 @@ class PersonControllerTest { void createPerson_returns200_withAllSixFields() throws Exception { UUID id = UUID.randomUUID(); 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); mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .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\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.firstName").value("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 ───────────────────────────────────────── diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java index 9b778292..b14b2691 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java @@ -5,7 +5,9 @@ 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 java.time.LocalDate; import java.util.Optional; import java.util.UUID; @@ -97,24 +99,120 @@ class PersonImportUpsertTest { assertThat(result.getNotes()).isEqualTo("Nichte von Herbert"); } + // ─── life dates (ADR-025 extension via preferHumanDate, #773) ───────────── + @Test - void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() { - // Existing has a human-set birthYear and a blank deathYear. - Person existing = Person.builder() - .id(UUID.randomUUID()).sourceRef("clara-cram") - .lastName("Cram").birthYear(1890).deathYear(null).build(); - when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing)); + void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() { + // A human entered the exact birthday in-app; the spreadsheet only knows a year. + Person handDated = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).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") - .birthYear(1888).deathYear(1965) + .birthYear(1888) .personType(PersonType.PERSON).provisional(false).build(); Person result = personService.upsertBySourceRef(cmd); - assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept - assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14)); + 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 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index 48483476..add77786 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -18,6 +18,9 @@ import org.raddatz.familienarchiv.document.DocumentRepository; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import org.raddatz.familienarchiv.document.DatePrecision; + +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.Set; @@ -910,4 +913,61 @@ class PersonRepositoryTest { .setParameter(1, blockId).getSingleResult(); 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 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 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 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(); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 9cabe1ce..161e359c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -8,7 +8,9 @@ import org.mockito.junit.jupiter.MockitoExtension; 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.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonNameAlias; import org.raddatz.familienarchiv.person.PersonNameAliasType; @@ -17,6 +19,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository; import org.raddatz.familienarchiv.person.PersonRepository; import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -241,27 +244,49 @@ class PersonServiceTest { PersonUpdateDTO dto = new PersonUpdateDTO(); 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); assertThat(result.getFirstName()).isEqualTo("Maria"); assertThat(result.getLastName()).isEqualTo("Raddatz"); assertThat(result.getAlias()).isEqualTo("Oma Maria"); - assertThat(result.getBirthYear()).isEqualTo(1901); - assertThat(result.getDeathYear()).isEqualTo(1975); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14)); + 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"); } @Test - void createPerson_dto_yearValidationFires_whenBirthYearNegative() { + void createPerson_dto_rejectsDateWithUnknownPrecision() { 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)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .satisfies(e -> { + 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 @@ -600,114 +625,135 @@ class PersonServiceTest { assertThat(result.getNotes()).isNull(); } - // ─── updatePerson (birth/death years) ──────────────────────────────────── + // ─── updatePerson (birth/death dates) ──────────────────────────────────── @Test - void updatePerson_persistsBirthAndDeathYear() { + void updatePerson_persistsBirthAndDeathDateWithPrecision() { UUID id = UUID.randomUUID(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); 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); - assertThat(result.getBirthYear()).isEqualTo(1890); - assertThat(result.getDeathYear()).isEqualTo(1965); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY); } @Test - void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() { + void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() { UUID id = UUID.randomUUID(); 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)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .satisfies(e -> { + assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH); + assertThat(((DomainException) e).getStatus().value()).isEqualTo(400); + }); } @Test - void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() { - // Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw + void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() { + // 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(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); 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); - assertThat(result.getBirthYear()).isEqualTo(1890); - assertThat(result.getDeathYear()).isNull(); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1)); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); } @Test - void updatePerson_allowsSameYear() { + void updatePerson_allowsEqualBirthAndDeathDate() { UUID id = UUID.randomUUID(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); 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); - assertThat(result.getBirthYear()).isEqualTo(1900); - assertThat(result.getDeathYear()).isEqualTo(1900); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1)); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1)); } - // ─── Phase 1.3: Year range bounds (> 0) ────────────────────────────────── + // ─── Date/precision coherence (V76 CHECK constraint mirror) ───────────── @Test - void updatePerson_throwsBadRequest_whenBirthYearIsZero() { + void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() { UUID id = UUID.randomUUID(); 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)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .satisfies(e -> { + assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION); + assertThat(((DomainException) e).getStatus().value()).isEqualTo(400); + }); } @Test - void updatePerson_throwsBadRequest_whenBirthYearIsNegative() { + void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() { UUID id = UUID.randomUUID(); 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)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_DATE_PRECISION); } @Test - void updatePerson_throwsBadRequest_whenDeathYearIsZero() { + void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() { UUID id = UUID.randomUUID(); 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)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); - } - - @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); + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_DATE_PRECISION); } // ─── findCorrespondents ──────────────────────────────────────────────────