feat(stammbaum): family network — graph, badge, edit card, /stammbaum page (#358) #360

Merged
marcel merged 57 commits from feat/stammbaum-issue-358 into main 2026-04-28 19:33:33 +02:00
3 changed files with 175 additions and 6 deletions
Showing only changes of commit 050f2bc929 - Show all commits

View File

@@ -107,7 +107,9 @@ public class RelationshipService {
.build();
try {
return toDTO(relationshipRepository.save(rel));
// saveAndFlush so the unique_rel constraint violates synchronously and is
// caught here, not at commit time outside the @Transactional boundary.
return toDTO(relationshipRepository.saveAndFlush(rel));
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,

View File

@@ -0,0 +1,167 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Sara blocker 1 — service+DB integration over the family-network constraints.
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
* sibling index actually fire.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({
PostgresContainerConfig.class,
FlywayConfig.class,
RelationshipService.class,
RelationshipInferenceService.class,
PersonService.class
})
class RelationshipServiceIntegrationTest {
@Autowired RelationshipService relationshipService;
@Autowired PersonRepository personRepository;
@Autowired PersonRelationshipRepository relationshipRepository;
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
@Autowired PersonNameAliasRepository aliasRepository;
@Autowired EntityManager entityManager;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
relationshipRepository.deleteAll();
aliasRepository.deleteAll();
personRepository.deleteAll();
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
}
@Test
void addRelationship_stores_and_is_readable() {
var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", 1900, null, null);
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.getId());
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
assertThat(rels).hasSize(1);
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
}
@Test
void addRelationship_throws_409_when_duplicate() {
var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null);
relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), "PARENT_OF", null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
}
@Test
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null));
// Charlie is unrelated to this row.
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
// The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent();
}
@Test
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), "SPOUSE_OF", null, null, null));
relationshipService.deleteRelationship(bob.getId(), created.id());
assertThat(relationshipRepository.findById(created.id())).isEmpty();
}
@Test
void setFamilyMember_true_makes_person_appear_in_network() {
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(charlie.getId(), "PARENT_OF", null, null, null));
NetworkDTO before = relationshipService.getFamilyNetwork();
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
relationshipService.setFamilyMember(charlie.getId(), true);
NetworkDTO after = relationshipService.getFamilyNetwork();
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
assertThat(after.edges())
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
}
@Test
void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null));
UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent();
// Detach managed entities so deleteById's cascade isn't fought by the
// persistence context (the rel row still references bob in memory).
entityManager.flush();
entityManager.clear();
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
personRepository.deleteById(bob.getId());
personRepository.flush();
assertThat(relationshipRepository.findById(relId)).isEmpty();
}
}

View File

@@ -82,7 +82,7 @@ class RelationshipServiceTest {
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).save(any());
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
@@ -91,7 +91,7 @@ class RelationshipServiceTest {
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.save(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
@@ -107,7 +107,7 @@ class RelationshipServiceTest {
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).save(any());
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
@@ -119,7 +119,7 @@ class RelationshipServiceTest {
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).save(any());
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
@@ -128,7 +128,7 @@ class RelationshipServiceTest {
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.save(any())).thenAnswer(inv -> {
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
PersonRelationship r = inv.getArgument(0);
r.setId(UUID.randomUUID());
r.setCreatedAt(Instant.now());