From 1e77d6d98ced15d9e027d54d7e348bdf390dc381 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 28 May 2026 15:23:38 +0200 Subject: [PATCH] feat(person): generation on PersonUpsertCommand + PersonUpdateDTO (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../person/PersonUpdateDTO.java | 7 ++ .../person/PersonUpsertCommand.java | 1 + .../person/PersonControllerTest.java | 65 +++++++++++++++++++ 3 files changed, 73 insertions(+) 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 2cce1ea0..a462943c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java @@ -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; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpsertCommand.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpsertCommand.java index 63864ab6..a1a5c71e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpsertCommand.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpsertCommand.java @@ -18,6 +18,7 @@ public record PersonUpsertCommand( String notes, Integer birthYear, Integer deathYear, + Integer generation, boolean familyMember, PersonType personType, boolean provisional 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 d43e9a9a..9bbd75be 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -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)); + } }