diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 75b265d0..50ff4ee9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -194,6 +194,12 @@ public interface PersonRepository extends JpaRepository { @Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true) void reassignSender(@Param("source") UUID source, @Param("target") UUID target); + // Used by deletePerson: detach a deleted person from documents they sent, so the hard + // delete cannot orphan a documents.sender_id FK (the column is nullable). + @Modifying + @Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true) + void reassignSenderToNull(@Param("source") UUID source); + @Modifying @Query(value = """ INSERT INTO document_receivers (document_id, person_id) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index d2e894bf..baad6e2a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -45,6 +45,51 @@ public class PersonService { return personRepository.findTopByDocumentCount(limit); } + /** + * Filtered, paginated directory query. The slice and the total are derived from one + * shared WHERE clause (see {@link PersonRepository#FILTER_WHERE}) so totalElements can + * never drift from the rendered page. {@code type} is passed as the enum name because the + * native query compares against the string column. + */ + public PersonSearchResult search(PersonFilter filter, int page, int size, String q) { + String type = filter.type() == null ? null : filter.type().name(); + String query = (q == null || q.isBlank()) ? null : q.trim(); + int offset = page * size; + + List items = personRepository.findByFilter( + type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(), + filter.readerDefault(), query, size, offset); + long total = personRepository.countByFilter( + type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(), + filter.readerDefault(), query); + + return PersonSearchResult.paged(items, page, size, total); + } + + /** + * Clears the {@code provisional} flag — a deliberate state transition exposed as + * {@code PATCH /api/persons/{id}/confirm}, never as a mass-assignable DTO field (CWE-915). + */ + @Transactional + public Person confirmPerson(UUID id) { + Person person = getById(id); + person.setProvisional(false); + return personRepository.save(person); + } + + /** + * Hard-deletes a person used by triage. Detaches the person from any documents they + * sent (nulls sender_id) and from any received-document references first, so the delete + * cannot orphan an FK and fail with a 500. + */ + @Transactional + public void deletePerson(UUID id) { + getById(id); + personRepository.reassignSenderToNull(id); + personRepository.deleteReceiverReferences(id); + personRepository.deleteById(id); + } + public Person getById(UUID id) { return personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); 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 e8d5ed97..fd90371c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java @@ -63,4 +63,53 @@ class PersonServiceIntegrationTest { assertThat(result.getFirstName()).isEqualTo("Clara"); assertThat(result.getLastName()).isEqualTo("Cram"); } + + // ─── #667: confirm round-trip + reader-default semantics ────────────────── + + @Test + void search_readerDefault_hidesProvisionalZeroDocumentPerson() { + personRepository.save(Person.builder() + .firstName("Unbe").lastName("Staetigt").provisional(true).build()); + + PersonSearchResult result = personService.search(PersonFilter.cleanDefault(), 0, 50, null); + + assertThat(result.items()).noneMatch(p -> p.getLastName().equals("Staetigt")); + assertThat(result.totalElements()).isEqualTo(result.items().size()); + } + + @Test + void search_showAll_includesProvisionalZeroDocumentPerson() { + personRepository.save(Person.builder() + .firstName("Unbe").lastName("Staetigt").provisional(true).build()); + + PersonSearchResult result = personService.search(PersonFilter.showAll(), 0, 50, null); + + assertThat(result.items()).anyMatch(p -> p.getLastName().equals("Staetigt")); + } + + @Test + void confirmPerson_clearsProvisional_andShowAllTreatsItAsConfirmed() { + Person provisional = personRepository.save(Person.builder() + .firstName("Bald").lastName("Bestaetigt").provisional(true).build()); + + personService.confirmPerson(provisional.getId()); + + Person reloaded = personRepository.findById(provisional.getId()).orElseThrow(); + assertThat(reloaded.isProvisional()).isFalse(); + + PersonSearchResult showAll = personService.search(PersonFilter.showAll(), 0, 50, null); + assertThat(showAll.items()) + .filteredOn(p -> p.getId().equals(provisional.getId())) + .allMatch(p -> !p.isProvisional()); + } + + @Test + void deletePerson_removesPerson() { + Person target = personRepository.save(Person.builder() + .firstName("Weg").lastName("Person").provisional(true).build()); + + personService.deletePerson(target.getId()); + + assertThat(personRepository.findById(target.getId())).isEmpty(); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 1ad9ce27..8f6cf03c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -87,6 +87,111 @@ class PersonServiceTest { verify(personRepository, never()).findAllWithDocumentCount(); } + // ─── #667: search (filter + pagination) ────────────────────────────────── + + @Test + void search_returnsPagedResult_withTotalsFromCountQuery() { + PersonFilter filter = PersonFilter.cleanDefault(); + when(personRepository.countByFilter(null, null, null, null, true, null)).thenReturn(120L); + when(personRepository.findByFilter(null, null, null, null, true, null, 50, 0)) + .thenReturn(List.of()); + + PersonSearchResult result = personService.search(filter, 0, 50, null); + + assertThat(result.totalElements()).isEqualTo(120L); + assertThat(result.pageNumber()).isEqualTo(0); + assertThat(result.pageSize()).isEqualTo(50); + assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50) + } + + @Test + void search_passesTypeAsEnumName_toRepository() { + PersonFilter filter = PersonFilter.builder().type(PersonType.INSTITUTION).build(); + when(personRepository.countByFilter("INSTITUTION", null, null, null, false, null)).thenReturn(0L); + when(personRepository.findByFilter("INSTITUTION", null, null, null, false, null, 50, 0)) + .thenReturn(List.of()); + + personService.search(filter, 0, 50, null); + + verify(personRepository).findByFilter("INSTITUTION", null, null, null, false, null, 50, 0); + } + + @Test + void search_computesOffset_fromPageAndSize() { + PersonFilter filter = PersonFilter.showAll(); + when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L); + when(personRepository.findByFilter(null, null, null, null, false, null, 20, 40)) + .thenReturn(List.of()); + + personService.search(filter, 2, 20, null); // offset = page * size = 40 + + verify(personRepository).findByFilter(null, null, null, null, false, null, 20, 40); + } + + @Test + void search_trimsBlankQueryToNull() { + PersonFilter filter = PersonFilter.showAll(); + when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L); + when(personRepository.findByFilter(null, null, null, null, false, null, 50, 0)) + .thenReturn(List.of()); + + personService.search(filter, 0, 50, " "); + + verify(personRepository).findByFilter(null, null, null, null, false, null, 50, 0); + } + + // ─── #667: confirmPerson ────────────────────────────────────────────────── + + @Test + void confirmPerson_clearsProvisionalFlag() { + UUID id = UUID.randomUUID(); + Person provisional = Person.builder().id(id).firstName("Inferred").lastName("Person").provisional(true).build(); + when(personRepository.findById(id)).thenReturn(Optional.of(provisional)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Person result = personService.confirmPerson(id); + + assertThat(result.isProvisional()).isFalse(); + verify(personRepository).save(argThat(p -> !p.isProvisional())); + } + + @Test + void confirmPerson_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(personRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> personService.confirmPerson(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) + .isEqualTo(404); + } + + // ─── #667: deletePerson ─────────────────────────────────────────────────── + + @Test + void deletePerson_deletes_whenPersonExists() { + UUID id = UUID.randomUUID(); + Person person = Person.builder().id(id).firstName("Weg").lastName("Person").build(); + when(personRepository.findById(id)).thenReturn(Optional.of(person)); + + personService.deletePerson(id); + + verify(personRepository).reassignSenderToNull(id); + verify(personRepository).deleteReceiverReferences(id); + verify(personRepository).deleteById(id); + } + + @Test + void deletePerson_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(personRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> personService.deletePerson(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) + .isEqualTo(404); + } + // ─── createPerson ───────────────────────────────────────────────────────── @Test