diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java new file mode 100644 index 00000000..61392ca2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java @@ -0,0 +1,129 @@ +package org.raddatz.familienarchiv.importing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +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.PersonService; +import org.raddatz.familienarchiv.person.PersonType; +import org.raddatz.familienarchiv.person.PersonUpsertCommand; +import org.raddatz.familienarchiv.person.relationship.RelationType; +import org.raddatz.familienarchiv.person.relationship.RelationshipService; +import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Loads {@code canonical-persons-tree.json} into the person + relationship domains. + * Tree persons are upserted via {@link PersonService} keyed on the shared + * {@code personId} slug (which Phase 1 #670 now emits into the tree), so they reconcile + * with the register rather than duplicating it. Relationships reference persons by the + * tree's local {@code rowId}; each side is mapped to the upserted person's UUID and + * created through {@link RelationshipService} (never the relationship repository — + * layering rule). A duplicate relationship on re-import is swallowed for idempotency. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PersonTreeImporter { + + private final PersonService personService; + private final RelationshipService relationshipService; + private final ObjectMapper objectMapper; + + public int load(File artifact) { + JsonNode root = readTree(artifact); + Map idByRowId = upsertPersons(root.path("persons")); + int relationships = createRelationships(root.path("relationships"), idByRowId); + log.info("Imported {} tree persons and {} relationships from {}", + idByRowId.size(), relationships, artifact.getName()); + return idByRowId.size(); + } + + private JsonNode readTree(File artifact) { + try { + return objectMapper.readTree(artifact); + } catch (Exception e) { + throw DomainException.badRequest(ErrorCode.IMPORT_ARTIFACT_INVALID, + "Unreadable canonical artifact: " + artifact.getName()); + } + } + + private Map upsertPersons(JsonNode persons) { + Map idByRowId = new HashMap<>(); + for (JsonNode node : persons) { + String personId = text(node, "personId"); + if (personId.isBlank()) continue; + Person person = personService.upsertBySourceRef(toCommand(node, personId)); + idByRowId.put(text(node, "rowId"), person.getId()); + } + return idByRowId; + } + + private PersonUpsertCommand toCommand(JsonNode node, String personId) { + return PersonUpsertCommand.builder() + .sourceRef(personId) + .lastName(blankToNull(text(node, "lastName"))) + .firstName(blankToNull(text(node, "firstName"))) + .maidenName(blankToNull(text(node, "maidenName"))) + .notes(blankToNull(text(node, "notes"))) + .birthYear(intOrNull(node, "birthYear")) + .deathYear(intOrNull(node, "deathYear")) + .familyMember(node.path("familyMember").asBoolean(false)) + .personType(PersonType.PERSON) + .provisional(false) + .build(); + } + + private int createRelationships(JsonNode relationships, Map idByRowId) { + int created = 0; + for (JsonNode node : relationships) { + UUID person = idByRowId.get(text(node, "personId")); + UUID related = idByRowId.get(text(node, "relatedPersonId")); + if (person == null || related == null) { + log.warn("Skipping tree relationship with unresolved rowId: {} -> {}", + text(node, "personId"), text(node, "relatedPersonId")); + continue; + } + if (addRelationshipIdempotently(person, related, text(node, "type"))) { + created++; + } + } + return created; + } + + private boolean addRelationshipIdempotently(UUID person, UUID related, String type) { + try { + relationshipService.addRelationship(person, + new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null)); + return true; + } catch (DomainException e) { + if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP + || e.getCode() == ErrorCode.CIRCULAR_RELATIONSHIP) { + return false; + } + throw e; + } + } + + private static String text(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? "" : value.asText(); + } + + private static Integer intOrNull(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asInt(); + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s; + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java new file mode 100644 index 00000000..bef9797b --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java @@ -0,0 +1,138 @@ +package org.raddatz.familienarchiv.importing; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.person.PersonUpsertCommand; +import org.raddatz.familienarchiv.person.relationship.RelationType; +import org.raddatz.familienarchiv.person.relationship.RelationshipService; +import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PersonTreeImporterTest { + + @Test + void load_upsertsTreePersonBySourceRef_withFamilyMemberFlag(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + RelationshipService relationshipService = mock(RelationshipService.class); + when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0))); + Path json = write(tempDir, """ + {"persons":[ + {"rowId":"row_002","firstName":"Elsgard","lastName":"Allemeyer","maidenName":"Wöhler", + "notes":"Nichte","birthYear":1920,"deathYear":1999,"familyMember":true,"personId":"allemeyer-elsgard"} + ],"relationships":[]} + """); + + new PersonTreeImporter(personService, relationshipService, new com.fasterxml.jackson.databind.ObjectMapper()) + .load(json.toFile()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PersonUpsertCommand.class); + verify(personService).upsertBySourceRef(captor.capture()); + PersonUpsertCommand cmd = captor.getValue(); + assertThat(cmd.sourceRef()).isEqualTo("allemeyer-elsgard"); + assertThat(cmd.familyMember()).isTrue(); + assertThat(cmd.provisional()).isFalse(); + } + + @Test + void load_createsRelationship_resolvingRowIdsToUpsertedPersons(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + RelationshipService relationshipService = mock(RelationshipService.class); + UUID idA = UUID.randomUUID(); + UUID idB = UUID.randomUUID(); + when(personService.upsertBySourceRef(any())).thenAnswer(inv -> { + PersonUpsertCommand c = inv.getArgument(0); + return Person.builder().id(c.sourceRef().equals("a") ? idA : idB) + .sourceRef(c.sourceRef()).lastName(c.lastName()).build(); + }); + Path json = write(tempDir, """ + {"persons":[ + {"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"}, + {"rowId":"row_b","lastName":"B","familyMember":true,"personId":"b"} + ],"relationships":[ + {"personId":"row_a","relatedPersonId":"row_b","type":"SPOUSE_OF","source":"verheiratet_mit"} + ]} + """); + + new PersonTreeImporter(personService, relationshipService, new com.fasterxml.jackson.databind.ObjectMapper()) + .load(json.toFile()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class); + verify(relationshipService).addRelationship(eq(idA), captor.capture()); + assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB); + assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF); + } + + @Test + void load_swallowsDuplicateRelationship_forIdempotentReimport(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + RelationshipService relationshipService = mock(RelationshipService.class); + when(personService.upsertBySourceRef(any())) + .thenAnswer(inv -> personOf(inv.getArgument(0))); + doThrow(DomainException.conflict(ErrorCode.DUPLICATE_RELATIONSHIP, "exists")) + .when(relationshipService).addRelationship(any(), any()); + Path json = write(tempDir, """ + {"persons":[ + {"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"}, + {"rowId":"row_b","lastName":"B","familyMember":true,"personId":"b"} + ],"relationships":[ + {"personId":"row_a","relatedPersonId":"row_b","type":"SPOUSE_OF","source":"verheiratet_mit"} + ]} + """); + + PersonTreeImporter importer = new PersonTreeImporter(personService, relationshipService, + new com.fasterxml.jackson.databind.ObjectMapper()); + + // Must not propagate the conflict — re-import is idempotent. + importer.load(json.toFile()); + + verify(relationshipService).addRelationship(any(), any()); + } + + @Test + void load_skipsRelationship_whenRowIdUnresolved(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + RelationshipService relationshipService = mock(RelationshipService.class); + when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0))); + Path json = write(tempDir, """ + {"persons":[ + {"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"} + ],"relationships":[ + {"personId":"row_a","relatedPersonId":"row_ghost","type":"SPOUSE_OF","source":"x"} + ]} + """); + + new PersonTreeImporter(personService, relationshipService, new com.fasterxml.jackson.databind.ObjectMapper()) + .load(json.toFile()); + + verify(relationshipService, org.mockito.Mockito.never()).addRelationship(any(), any()); + } + + private static Person personOf(PersonUpsertCommand cmd) { + return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build(); + } + + private Path write(Path dir, String json) throws Exception { + Path file = dir.resolve("canonical-persons-tree.json"); + Files.writeString(file, json); + return file; + } +}