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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,23 +418,40 @@ 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");
|
|
||||||
}
|
}
|
||||||
if (birthYear != null && deathYear != null && birthYear > deathYear) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||||
|
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date is set but its precision is missing or UNKNOWN");
|
||||||
}
|
}
|
||||||
|
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date precision " + precision + " is set without a date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
||||||
|
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -407,7 +459,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 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());
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user