feat(person): generation on PersonUpsertCommand + PersonUpdateDTO (#689)

Adds the optional generation field to both DTOs:

- PersonUpsertCommand gains Integer generation in the canonical-import
  builder chain; service wiring lands in the next commit.
- PersonUpdateDTO gains @Min(0)@Max(10) Integer generation, the form-path
  surface. The constraints mirror the V70 CHECK so validation fails fast
  at the controller before reaching the DB.

PersonControllerTest pins the validation behaviour: -1 → 400, 11 → 400,
null → 200, 3 → 200 for both PUT (update) and POST (create) paths. The
GlobalExceptionHandler maps MethodArgumentNotValidException to
VALIDATION_ERROR so the frontend's extractErrorCode keeps working.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 15:23:38 +02:00
parent f22508ca91
commit 1e77d6d98c
3 changed files with 73 additions and 0 deletions

View File

@@ -1,5 +1,7 @@
package org.raddatz.familienarchiv.person;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -21,4 +23,9 @@ public class PersonUpdateDTO {
private String notes;
private Integer birthYear;
private Integer deathYear;
// Mirror of the persons.generation CHECK constraint (V70).
// null clears the field; 0..10 is the allowlist of valid indices.
@Min(0)
@Max(10)
private Integer generation;
}

View File

@@ -18,6 +18,7 @@ public record PersonUpsertCommand(
String notes,
Integer birthYear,
Integer deathYear,
Integer generation,
boolean familyMember,
PersonType personType,
boolean provisional

View File

@@ -718,4 +718,69 @@ class PersonControllerTest {
.content("{\"lastName\":\"de Gruyter\"}"))
.andExpect(status().isBadRequest());
}
// ─── generation field validation (#689) ────────────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenGenerationAboveRange() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":11}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenGenerationBelowRange() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":-1}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns200_whenGenerationNull() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.updatePerson(any(), any())).thenReturn(saved);
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":null}"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns200_whenGenerationInRange() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
when(personService.updatePerson(any(), any())).thenReturn(saved);
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":3}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.generation").value(3));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_whenGenerationInRange() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).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\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":3}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.generation").value(3));
}
}