feat(import): warn on generation monotonicity violations (#689)

Inject RelationshipService into CanonicalImportOrchestrator and walk
PARENT_OF edges in the family network after both person loaders finish
(before documents). For every edge where child.generation is set and
not strictly deeper than parent.generation, log a WARN — soft check,
never fails the batch.

Reads through getFamilyNetwork() per the layering rule (orchestrator
never touches PersonRelationshipRepository directly). Curators see the
warning in the import log; the rest of the pipeline is unaffected so
data with curatorial gaps still loads cleanly.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 15:39:29 +02:00
parent 40511535eb
commit 8f163f9b77
2 changed files with 91 additions and 1 deletions

View File

@@ -7,12 +7,18 @@ import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -29,10 +35,12 @@ class CanonicalImportOrchestratorTest {
@Mock PersonRegisterImporter personRegisterImporter;
@Mock PersonTreeImporter personTreeImporter;
@Mock DocumentImporter documentImporter;
@Mock RelationshipService relationshipService;
private CanonicalImportOrchestrator orchestrator(Path dir) {
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter);
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter,
relationshipService);
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
return o;
}
@@ -53,6 +61,7 @@ class CanonicalImportOrchestratorTest {
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
@@ -68,6 +77,7 @@ class CanonicalImportOrchestratorTest {
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(3, List.of()));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
@@ -118,6 +128,7 @@ class CanonicalImportOrchestratorTest {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
List.of(new ImportStatus.SkippedFile("fake.pdf", ImportStatus.SkipReason.INVALID_PDF_SIGNATURE))));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
@@ -127,4 +138,46 @@ class CanonicalImportOrchestratorTest {
.extracting(ImportStatus.SkippedFile::filename)
.containsExactly("fake.pdf");
}
// ─── generation monotonicity soft-check (#689) ─────────────────────────────
@Test
void runImport_invokesGetFamilyNetwork_afterPersonLoaders_beforeDocuments(@TempDir Path dir) throws Exception {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
InOrder order = inOrder(personRegisterImporter, personTreeImporter, relationshipService, documentImporter);
order.verify(personRegisterImporter).load(any());
order.verify(personTreeImporter).load(any());
order.verify(relationshipService).getFamilyNetwork();
order.verify(documentImporter).load(any());
}
@Test
void runImport_completes_evenWhenMonotonicityViolatingEdgePresent(@TempDir Path dir) throws Exception {
// child.generation (2) <= parent.generation (3) — monotonicity violation.
// The orchestrator must WARN and continue; it must not abort or fail-closed.
writeAllArtifacts(dir);
UUID parentId = UUID.randomUUID();
UUID childId = UUID.randomUUID();
PersonNodeDTO parent = new PersonNodeDTO(parentId, "Parent", null, null, 3, true);
PersonNodeDTO child = new PersonNodeDTO(childId, "Child", null, null, 2, true);
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), parentId, childId,
"Parent", null, null, "Child", null, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
assertThat(o.getStatus().state()).isEqualTo(ImportStatus.State.DONE);
verify(documentImporter).load(any());
}
}