Compare commits
3 Commits
39276b179d
...
f124529ee8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f124529ee8 | ||
|
|
61ca5a6e40 | ||
|
|
516a0a3814 |
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||
@@ -33,8 +34,6 @@ public class PersonRegisterImporter {
|
||||
// carry an inline note. Out-of-range values are caught by the post-parse
|
||||
// range guard, not by the regex.
|
||||
private static final Pattern GENERATION_PATTERN = Pattern.compile("^\\s*G?\\s*(-?\\d+)");
|
||||
private static final int GENERATION_MIN = 0;
|
||||
private static final int GENERATION_MAX = 10;
|
||||
|
||||
private final PersonService personService;
|
||||
|
||||
@@ -68,16 +67,16 @@ public class PersonRegisterImporter {
|
||||
|
||||
/**
|
||||
* Parses an optional {@code G n} generation cell. Returns null for blanks,
|
||||
* non-matching strings, and any value outside {@value #GENERATION_MIN}..{@value #GENERATION_MAX}
|
||||
* (mirroring the V70 CHECK). Out-of-range values log a WARN but never abort
|
||||
* the batch — REQ-IMP-001.
|
||||
* non-matching strings, and any value outside the {@link PersonGeneration}
|
||||
* bounds (mirroring the V70 CHECK). Out-of-range values log a WARN but
|
||||
* never abort the batch — REQ-IMP-001.
|
||||
*/
|
||||
static Integer parseGeneration(String raw, String personId) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
Matcher m = GENERATION_PATTERN.matcher(raw);
|
||||
if (!m.find()) return null;
|
||||
int parsed = Integer.parseInt(m.group(1));
|
||||
if (parsed < GENERATION_MIN || parsed > GENERATION_MAX) {
|
||||
if (parsed < PersonGeneration.MIN_GENERATION || parsed > PersonGeneration.MAX_GENERATION) {
|
||||
log.warn("Skipping out-of-range generation '{}' for row {}", raw, personId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||
@@ -87,24 +88,21 @@ public class PersonTreeImporter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JSON {@code generation} value if present and in
|
||||
* {@value #GENERATION_MIN}..{@value #GENERATION_MAX}; null otherwise. Out-of-range
|
||||
* values log a WARN but never abort the batch — mirrors the register-importer
|
||||
* Returns the JSON {@code generation} value if present and within the
|
||||
* {@link PersonGeneration} bounds; null otherwise. Out-of-range values
|
||||
* log a WARN but never abort the batch — mirrors the register-importer
|
||||
* skip-and-warn policy.
|
||||
*/
|
||||
private static Integer generationOrNull(JsonNode node, String personId) {
|
||||
Integer raw = intOrNull(node, "generation");
|
||||
if (raw == null) return null;
|
||||
if (raw < GENERATION_MIN || raw > GENERATION_MAX) {
|
||||
if (raw < PersonGeneration.MIN_GENERATION || raw > PersonGeneration.MAX_GENERATION) {
|
||||
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static final int GENERATION_MIN = 0;
|
||||
private static final int GENERATION_MAX = 10;
|
||||
|
||||
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
||||
int created = 0;
|
||||
for (JsonNode node : relationships) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
/**
|
||||
* Single source of truth for the {@code persons.generation} value range.
|
||||
* The DB CHECK in V70, the {@code PersonUpdateDTO} Bean Validation annotations,
|
||||
* and the canonical importers all reference these constants so a future widening
|
||||
* (e.g. accepting {@code G −1} ancestors) happens in one place. Mirror this file
|
||||
* by hand in the V70 migration comment when adjusting bounds.
|
||||
*/
|
||||
public final class PersonGeneration {
|
||||
|
||||
public static final int MIN_GENERATION = 0;
|
||||
public static final int MAX_GENERATION = 10;
|
||||
|
||||
private PersonGeneration() {}
|
||||
}
|
||||
@@ -23,9 +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)
|
||||
// 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)
|
||||
@Max(PersonGeneration.MAX_GENERATION)
|
||||
private Integer generation;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
|
||||
ALTER TABLE persons ADD COLUMN generation SMALLINT;
|
||||
|
||||
-- Allowlist of valid generation indices. The upper bound is intentionally
|
||||
-- loose — current data tops out at G 5, but a future G 6 → G 10 widening
|
||||
-- needs no migration. A G −1 ancestor would require a separate one-shot
|
||||
-- shift migration (out of scope here; the layout's normalise step already
|
||||
-- handles negative seeds at render time).
|
||||
-- Allowlist of valid generation indices. The 0..10 bounds mirror
|
||||
-- PersonGeneration.MIN_GENERATION / MAX_GENERATION in Java — keep the
|
||||
-- two in sync (the DTO @Min/@Max and both importer range guards read from
|
||||
-- those Java constants). Current data tops out at G 5, but a future G 6 →
|
||||
-- G 10 widening needs no migration. A G −1 ancestor would require a
|
||||
-- separate one-shot shift migration (out of scope here; the layout's
|
||||
-- normalise step already handles negative seeds at render time).
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_generation_range
|
||||
CHECK (generation IS NULL OR generation BETWEEN 0 AND 10);
|
||||
|
||||
|
||||
@@ -746,6 +746,10 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns200_whenGenerationNull() throws Exception {
|
||||
// Symmetric body assertion: the response must echo generation as null (not
|
||||
// absent), so the frontend re-hydrates the "(none)" option after a clear.
|
||||
// Without this, the in-range test below would be the only end-to-end proof
|
||||
// that the field flows through the controller.
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.updatePerson(any(), any())).thenReturn(saved);
|
||||
|
||||
@@ -753,7 +757,8 @@ class PersonControllerTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||
+ "\"personType\":\"PERSON\",\"generation\":null}"))
|
||||
.andExpect(status().isOk());
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.generation").value(org.hamcrest.Matchers.nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -124,6 +124,59 @@ class PersonServiceIntegrationTest {
|
||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||
}
|
||||
|
||||
// ─── generation full-stack round-trip (#689) ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_clearGenerationToNull_readsBackNullFromDb() {
|
||||
// Sara's QA concern: pin the full PUT→DB→GET round-trip for the
|
||||
// null-clear path. Without this we only have the WebMvcTest mocked
|
||||
// boundary; nothing proved the JPA flush actually wrote SQL NULL.
|
||||
Person seeded = personRepository.save(Person.builder()
|
||||
.firstName("Hans").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).generation(3).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setFirstName("Hans");
|
||||
dto.setLastName("Raddatz");
|
||||
dto.setGeneration(null);
|
||||
|
||||
personService.updatePerson(seeded.getId(), dto);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||
assertThat(reloaded.getGeneration()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_setGenerationToZero_readsBackZeroFromDb() {
|
||||
// Pin the G 0 case end-to-end. The form-action spec covers that 0
|
||||
// doesn't get spread-dropped at the SvelteKit boundary; this test
|
||||
// covers that the controller + service + JPA chain preserves the
|
||||
// primitive zero (not coerced to null somewhere along the way).
|
||||
Person seeded = personRepository.save(Person.builder()
|
||||
.firstName("Walter").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setFirstName("Walter");
|
||||
dto.setLastName("Raddatz");
|
||||
dto.setGeneration(0);
|
||||
|
||||
personService.updatePerson(seeded.getId(), dto);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||
assertThat(reloaded.getGeneration()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||
|
||||
@@ -30,11 +30,17 @@ const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
||||
// too costly on a 320 px screen).
|
||||
const GUTTER_WIDTH_DESKTOP = 100;
|
||||
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
|
||||
let isMdOrUp = $state(false);
|
||||
// Seed synchronously so the first paint already has the right gutter state —
|
||||
// otherwise the test (and a brief flash on real CSR mount) would see the
|
||||
// pre-effect false. SSR has no window; the gutter stays hidden until hydrate.
|
||||
let isMdOrUp = $state(
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(GUTTER_MEDIA_QUERY).matches
|
||||
: false
|
||||
);
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
|
||||
isMdOrUp = mq.matches;
|
||||
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
|
||||
Reference in New Issue
Block a user