feat(stammbaum): RelationshipService + family_member toggle (TDD)

- Add PersonService.setFamilyMember (write, @Transactional) and
  findAllFamilyMembers; PersonRepository gains the
  findByFamilyMemberTrueOrderBy projection.
- RelationshipService orchestrates PersonService + the inference
  service; never reaches into PersonRepository directly. addRelationship
  guards self-relationship, year range, circular PARENT_OF (Nora B2),
  and DataIntegrityViolation→DUPLICATE_RELATIONSHIP. deleteRelationship
  enforces ownership from either side (Nora B1).
- Extend RelationshipDTO with personDisplayName + birth/death year so
  the frontend can render rows from either viewpoint.
- 8 unit tests, written against a stub (red), then green: FORBIDDEN
  delete, CIRCULAR add, DUPLICATE add, self-relationship, year range,
  happy-path persistence, ownership-from-object, RELATIONSHIP_NOT_FOUND.

Full backend suite: 1399/1399 green.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 14:20:15 +02:00
committed by marcel
parent acea4a60f2
commit 790c6f5b02
5 changed files with 389 additions and 3 deletions

View File

@@ -0,0 +1,185 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Felix Brandt — TDD red for RelationshipService domain rules.
*
* <p>Required by the plan (Nora blockers 1 + 2):
* <ul>
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
* </ul>
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
* and ownership permitted from either side.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@Mock RelationshipInferenceService inferenceService;
@InjectMocks RelationshipService service;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
alice = person("Alice");
bob = person("Bob");
charlie = person("Charlie");
}
// --- Nora blocker 1 ---
@Test
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
verify(relationshipRepository, never()).delete(any());
}
// --- Nora blocker 2 ---
@Test
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
when(personService.getById(bob.getId())).thenReturn(bob);
when(personService.getById(alice.getId())).thenReturn(alice);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new CreateRelationshipRequest(alice.getId(), "PARENT_OF", null, null, null);
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).save(any());
}
@Test
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
when(personService.getById(alice.getId())).thenReturn(alice);
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"));
var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
var dto = new CreateRelationshipRequest(alice.getId(), "FRIEND", null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).save(any());
}
@Test
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new CreateRelationshipRequest(bob.getId(), "FRIEND", 1950, 1940, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).save(any());
}
@Test
void addRelationship_persists_with_storage_truth() {
when(personService.getById(alice.getId())).thenReturn(alice);
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 -> {
PersonRelationship r = inv.getArgument(0);
r.setId(UUID.randomUUID());
r.setCreatedAt(Instant.now());
return r;
});
var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", 1900, null, "first born");
var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
assertThat(result.fromYear()).isEqualTo(1900);
assertThat(result.notes()).isEqualTo("first born");
}
@Test
void deleteRelationship_succeeds_when_viewpoint_is_object() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
// Bob is the storage related_person; deleting from his viewpoint should work.
service.deleteRelationship(bob.getId(), relId);
verify(relationshipRepository).delete(rel);
}
@Test
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
UUID relId = UUID.randomUUID();
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
// --- helpers ---
private static Person person(String name) {
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
return PersonRelationship.builder()
.id(id)
.person(parent)
.relatedPerson(child)
.relationType(RelationType.PARENT_OF)
.createdAt(Instant.now())
.build();
}
}