test(stammbaum): integration tests for relationship constraints
@DataJpaTest + Postgres Testcontainer; 7 cases per Sara blocker 1: - addRelationship_stores_and_is_readable - addRelationship_throws_409_when_duplicate (unique_rel) - addRelationship_throws_409_when_circular_parent - deleteRelationship_throws_403_when_rel_belongs_to_different_person - deleteRelationship_succeeds_for_symmetric_type_from_either_side - setFamilyMember_true_makes_person_appear_in_network - delete_person_cascades_to_relationships Service now uses saveAndFlush so the unique_rel violation surfaces synchronously inside the @Transactional method (otherwise the DataIntegrityViolation fires at commit time, outside the try-catch). Unit-test mocks updated accordingly. Backend suite: 1406/1406 green. Refs #358. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -107,7 +107,9 @@ public class RelationshipService {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
throw DomainException.conflict(
|
throw DomainException.conflict(
|
||||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ class RelationshipServiceTest {
|
|||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||||
verify(relationshipRepository, never()).save(any());
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -91,7 +91,7 @@ class RelationshipServiceTest {
|
|||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
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);
|
var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
@@ -107,7 +107,7 @@ class RelationshipServiceTest {
|
|||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
verify(relationshipRepository, never()).save(any());
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -119,7 +119,7 @@ class RelationshipServiceTest {
|
|||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
verify(relationshipRepository, never()).save(any());
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -128,7 +128,7 @@ class RelationshipServiceTest {
|
|||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
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);
|
PersonRelationship r = inv.getArgument(0);
|
||||||
r.setId(UUID.randomUUID());
|
r.setId(UUID.randomUUID());
|
||||||
r.setCreatedAt(Instant.now());
|
r.setCreatedAt(Instant.now());
|
||||||
|
|||||||
Reference in New Issue
Block a user