diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java index 03ed5d54..14f46018 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java @@ -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, diff --git a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java new file mode 100644 index 00000000..8654fa1d --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java @@ -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 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(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java index 4383796a..be915e3b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java @@ -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());