From 362672cdbfeb468e5068e86c12b152b86bbf6783 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 14:19:06 +0200 Subject: [PATCH] test(person): pin query count-parity and delete FK-detach ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add countByFilter parity coverage for the query (LIKE) path so the shared FILTER_WHERE slice and count can't drift, and an integration test proving deletePerson detaches a person referenced as both sender and receiver before delete — the documents survive (sender nulled, receiver link removed) with no FK orphan. Refs #667 Co-Authored-By: Claude Opus 4.7 --- .../person/PersonRepositoryTest.java | 15 +++++ .../person/PersonServiceIntegrationTest.java | 55 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index 60e63084..910e701e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -648,6 +648,21 @@ class PersonRepositoryTest { assertThat(count).isEqualTo(1); } + @Test + void countByFilter_query_matchesSliceSize() { + // The whole point of the shared FILTER_WHERE is that the slice and the count can never + // drift. Pin the query (LIKE) path explicitly: countByFilter must equal the slice size + // so a future edit to one query's LIKE clause is caught. + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, null, null, false, "Verlag", 50, 0); + long count = personRepository.countByFilter(null, null, null, null, false, "Verlag"); + + assertThat(count).isEqualTo(slice.size()); + assertThat(count).isEqualTo(1); + } + @Test void findByFilter_projectsDocumentCount() { seedDirectoryFixture(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java index fd90371c..0578f5fb 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java @@ -2,6 +2,9 @@ package org.raddatz.familienarchiv.person; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentStatus; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonType; import org.raddatz.familienarchiv.person.PersonRepository; @@ -13,6 +16,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.s3.S3Client; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import java.util.Set; + import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @@ -24,6 +32,9 @@ class PersonServiceIntegrationTest { @MockitoBean S3Client s3Client; @Autowired PersonService personService; @Autowired PersonRepository personRepository; + @Autowired DocumentRepository documentRepository; + + @PersistenceContext EntityManager entityManager; @Test void findOrCreateByAlias_skipReturnsNull_noRecordCreated() { @@ -112,4 +123,48 @@ class PersonServiceIntegrationTest { assertThat(personRepository.findById(target.getId())).isEmpty(); } + + @Test + void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() { + // A person referenced as BOTH a document sender and a document receiver must delete + // cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first + // (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and + // the documents themselves survive. + Person target = personRepository.save(Person.builder() + .firstName("Weg").lastName("Person").provisional(true).build()); + Person bystander = personRepository.save(Person.builder() + .firstName("Bleibt").lastName("Hier").build()); + + Document sent = documentRepository.save(Document.builder() + .title("Sent letter").originalFilename("sent.pdf") + .status(DocumentStatus.UPLOADED).sender(target).build()); + Document received = documentRepository.save(Document.builder() + .title("Received letter").originalFilename("received.pdf") + .status(DocumentStatus.UPLOADED).sender(bystander) + .receivers(new java.util.HashSet<>(Set.of(target))).build()); + + // Persist the fixture and detach everything so the native @Modifying deletes operate on + // the database directly without the persistence context holding stale references that + // would re-flush a now-deleted person as a transient association. + entityManager.flush(); + entityManager.clear(); + + personService.deletePerson(target.getId()); + + // Native @Modifying queries bypass the persistence context — clear it so the asserting + // reads observe the post-delete database state, not stale managed entities. + entityManager.flush(); + entityManager.clear(); + + assertThat(personRepository.findById(target.getId())).isEmpty(); + + Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow(); + assertThat(reloadedSent.getSender()).isNull(); + + Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow(); + assertThat(reloadedReceived.getReceivers()) + .noneMatch(p -> p.getId().equals(target.getId())); + // The other person and the documents themselves survive the delete. + assertThat(personRepository.findById(bystander.getId())).isPresent(); + } }