diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java index ece8292a..cd2b37d3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; import lombok.extern.slf4j.Slf4j; @@ -30,6 +31,12 @@ public class GlobalExceptionHandler { return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message)); } + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatus(ResponseStatusException ex) { + return ResponseEntity.status(ex.getStatusCode()) + .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { log.error("Unhandled exception", ex); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index dbc9084c..e04ba664 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.DocumentService; @@ -11,15 +12,20 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.raddatz.familienarchiv.config.SecurityConfig; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.util.Collections; +import java.util.List; import java.util.UUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(PersonController.class) @@ -32,6 +38,101 @@ class PersonControllerTest { @MockitoBean DocumentService documentService; @MockitoBean CustomUserDetailsService customUserDetailsService; + // ─── GET /api/persons ───────────────────────────────────────────────────── + + @Test + void getPersons_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/persons")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getPersons_returns200_withEmptyList() throws Exception { + when(personService.findAll(null)).thenReturn(Collections.emptyList()); + mockMvc.perform(get("/api/persons")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + void getPersons_delegatesQueryParam_toService() throws Exception { + Person person = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); + when(personService.findAll("Hans")).thenReturn(List.of(person)); + + mockMvc.perform(get("/api/persons").param("q", "Hans")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].firstName").value("Hans")); + } + + // ─── GET /api/persons/{id} ──────────────────────────────────────────────── + + @Test + void getPerson_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getPerson_returns200_whenFound() throws Exception { + UUID id = UUID.randomUUID(); + Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build(); + when(personService.getById(id)).thenReturn(person); + + mockMvc.perform(get("/api/persons/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("Anna")); + } + + // ─── GET /api/persons/{id}/correspondents ───────────────────────────────── + + @Test + void getCorrespondents_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/persons/{id}/correspondents", UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getCorrespondents_returns200_withoutFilter() throws Exception { + UUID personId = UUID.randomUUID(); + when(personService.findCorrespondents(personId, null)).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/persons/{id}/correspondents", personId)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + void getCorrespondents_returns200_withFilter() throws Exception { + UUID personId = UUID.randomUUID(); + Person correspondent = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Gruyter").build(); + when(personService.findCorrespondents(personId, "Walter")).thenReturn(List.of(correspondent)); + + mockMvc.perform(get("/api/persons/{id}/correspondents", personId).param("q", "Walter")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].firstName").value("Walter")); + } + + // ─── GET /api/persons/{id}/documents ────────────────────────────────────── + + @Test + void getPersonDocuments_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/persons/{id}/documents", UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getPersonDocuments_returns200_whenAuthenticated() throws Exception { + UUID personId = UUID.randomUUID(); + when(documentService.getDocumentsBySender(personId)).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/persons/{id}/documents", personId)) + .andExpect(status().isOk()); + } + // ─── GET /api/persons/{id}/received-documents ───────────────────────────── @Test @@ -49,4 +150,145 @@ class PersonControllerTest { mockMvc.perform(get("/api/persons/{id}/received-documents", personId)) .andExpect(status().isOk()); } + + // ─── POST /api/persons ──────────────────────────────────────────────────── + + @Test + void createPerson_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void createPerson_returns400_whenFirstNameIsMissing() throws Exception { + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"lastName\":\"Müller\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void createPerson_returns400_whenFirstNameIsBlank() throws Exception { + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\" \",\"lastName\":\"Müller\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void createPerson_returns400_whenLastNameIsMissing() throws Exception { + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void createPerson_returns400_whenLastNameIsBlank() throws Exception { + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\" \"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void createPerson_returns200_whenValid() throws Exception { + Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); + when(personService.createPerson(eq("Hans"), eq("Müller"), any())).thenReturn(saved); + + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("Hans")); + } + + // ─── PUT /api/persons/{id} ──────────────────────────────────────────────── + + @Test + void updatePerson_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void updatePerson_returns400_whenFirstNameIsBlank() throws Exception { + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"\",\"lastName\":\"Müller\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void updatePerson_returns400_whenLastNameIsNull() throws Exception { + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void updatePerson_returns200_whenValid() throws Exception { + UUID id = UUID.randomUUID(); + Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build(); + when(personService.updatePerson(eq(id), any())).thenReturn(updated); + + mockMvc.perform(put("/api/persons/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lastName").value("Müller")); + } + + // ─── POST /api/persons/{id}/merge ───────────────────────────────────────── + + @Test + void mergePerson_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception { + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception { + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetPersonId\":\" \"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void mergePerson_returns204_whenValid() throws Exception { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + + mockMvc.perform(post("/api/persons/{id}/merge", sourceId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetPersonId\":\"" + targetId + "\"}")) + .andExpect(status().isNoContent()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java new file mode 100644 index 00000000..ccdd3408 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -0,0 +1,252 @@ +package org.raddatz.familienarchiv.repository; + +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.model.Document; +import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.domain.Specification; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class DocumentSpecificationsTest { + + @Autowired DocumentRepository documentRepository; + @Autowired PersonRepository personRepository; + @Autowired TagRepository tagRepository; + + private Person sender; + private Person receiver; + private Document briefEarly; + private Document briefLate; + private Document photoDoc; + + @BeforeEach + void setUp() { + documentRepository.deleteAll(); + personRepository.deleteAll(); + tagRepository.deleteAll(); + + sender = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build()); + receiver = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + + Tag tagFamilie = tagRepository.save(Tag.builder().name("Familie").build()); + Tag tagUrlaub = tagRepository.save(Tag.builder().name("Urlaub").build()); + + briefEarly = documentRepository.save(Document.builder() + .title("Alter Brief") + .originalFilename("brief_early.pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(LocalDate.of(1940, 5, 1)) + .transcription("Liebe Anna, ich schreibe dir aus dem Krieg") + .location("Berlin") + .sender(sender) + .receivers(Set.of(receiver)) + .tags(Set.of(tagFamilie)) + .build()); + + briefLate = documentRepository.save(Document.builder() + .title("Neuerer Brief") + .originalFilename("brief_late.pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(LocalDate.of(1960, 8, 15)) + .sender(sender) + .tags(Set.of(tagUrlaub)) + .build()); + + photoDoc = documentRepository.save(Document.builder() + .title("Familienfoto") + .originalFilename("familienfoto.jpg") + .status(DocumentStatus.PLACEHOLDER) + .build()); + } + + // ─── hasText ────────────────────────────────────────────────────────────── + + @Test + void hasText_returnsAllDocuments_whenTextIsNull() { + List result = documentRepository.findAll(Specification.where(hasText(null))); + assertThat(result).hasSize(3); + } + + @Test + void hasText_returnsAllDocuments_whenTextIsBlank() { + List result = documentRepository.findAll(Specification.where(hasText(" "))); + assertThat(result).hasSize(3); + } + + @Test + void hasText_filtersOnTitle() { + List result = documentRepository.findAll(Specification.where(hasText("familienfoto"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto"); + } + + @Test + void hasText_filtersOnOriginalFilename() { + List result = documentRepository.findAll(Specification.where(hasText("brief_late"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief"); + } + + @Test + void hasText_filtersOnTranscription() { + List result = documentRepository.findAll(Specification.where(hasText("schreibe dir"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasText_filtersOnLocation() { + List result = documentRepository.findAll(Specification.where(hasText("berlin"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasText_isCaseInsensitive() { + List result = documentRepository.findAll(Specification.where(hasText("BRIEF"))); + assertThat(result).extracting(Document::getTitle).containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief"); + } + + @Test + void hasText_returnsEmpty_whenNoMatch() { + List result = documentRepository.findAll(Specification.where(hasText("xyznotexist"))); + assertThat(result).isEmpty(); + } + + // ─── hasSender ──────────────────────────────────────────────────────────── + + @Test + void hasSender_returnsAllDocuments_whenPersonIdIsNull() { + List result = documentRepository.findAll(Specification.where(hasSender(null))); + assertThat(result).hasSize(3); + } + + @Test + void hasSender_filtersDocumentsBySender() { + List result = documentRepository.findAll(Specification.where(hasSender(sender.getId()))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief"); + } + + @Test + void hasSender_returnsEmpty_whenSenderHasNoDocuments() { + List result = documentRepository.findAll(Specification.where(hasSender(receiver.getId()))); + assertThat(result).isEmpty(); + } + + // ─── hasReceiver ────────────────────────────────────────────────────────── + + @Test + void hasReceiver_returnsAllDocuments_whenPersonIdIsNull() { + List result = documentRepository.findAll(Specification.where(hasReceiver(null))); + assertThat(result).hasSize(3); + } + + @Test + void hasReceiver_filtersDocumentsByReceiver() { + List result = documentRepository.findAll(Specification.where(hasReceiver(receiver.getId()))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasReceiver_returnsEmpty_whenReceiverHasNoDocuments() { + List result = documentRepository.findAll(Specification.where(hasReceiver(sender.getId()))); + assertThat(result).isEmpty(); + } + + // ─── isBetween ──────────────────────────────────────────────────────────── + + @Test + void isBetween_returnsAllDocuments_whenBothDatesAreNull() { + List result = documentRepository.findAll(Specification.where(isBetween(null, null))); + assertThat(result).hasSize(3); + } + + @Test + void isBetween_filtersByBothDates() { + List result = documentRepository.findAll( + Specification.where(isBetween(LocalDate.of(1939, 1, 1), LocalDate.of(1945, 12, 31)))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void isBetween_filtersByStartDateOnly() { + List result = documentRepository.findAll( + Specification.where(isBetween(LocalDate.of(1950, 1, 1), null))); + assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief"); + } + + @Test + void isBetween_filtersByEndDateOnly() { + List result = documentRepository.findAll( + Specification.where(isBetween(null, LocalDate.of(1945, 12, 31)))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void isBetween_returnsEmpty_whenNoDatesInRange() { + List result = documentRepository.findAll( + Specification.where(isBetween(LocalDate.of(1970, 1, 1), LocalDate.of(1980, 12, 31)))); + assertThat(result).isEmpty(); + } + + // ─── hasTags ────────────────────────────────────────────────────────────── + + @Test + void hasTags_returnsAllDocuments_whenTagListIsNull() { + List result = documentRepository.findAll(Specification.where(hasTags(null))); + assertThat(result).hasSize(3); + } + + @Test + void hasTags_returnsAllDocuments_whenTagListIsEmpty() { + List result = documentRepository.findAll(Specification.where(hasTags(List.of()))); + assertThat(result).hasSize(3); + } + + @Test + void hasTags_filtersDocumentsByTag() { + List result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie")))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasTags_isCaseInsensitive() { + List result = documentRepository.findAll(Specification.where(hasTags(List.of("familie")))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasTags_requiresAllTagsToBePresent_andLogic() { + // briefEarly has "Familie" but not "Urlaub" — should be excluded + List result = documentRepository.findAll( + Specification.where(hasTags(List.of("Familie", "Urlaub")))); + assertThat(result).isEmpty(); + } + + @Test + void hasTags_skipsEmptyTagNames() { + // An empty string in the tag list should be ignored + List result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie")))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasTags_returnsEmpty_whenTagDoesNotExist() { + List result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt")))); + assertThat(result).isEmpty(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java index 8ed8fb09..7f81dd15 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java @@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.repository; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; @@ -11,6 +13,7 @@ import org.springframework.context.annotation.Import; import java.util.List; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -22,6 +25,9 @@ class PersonRepositoryTest { @Autowired private PersonRepository personRepository; + @Autowired + private DocumentRepository documentRepository; + // ─── save and findById ──────────────────────────────────────────────────── @Test @@ -133,4 +139,163 @@ class PersonRepositoryTest { assertThat(found).isPresent(); assertThat(found.get().getFirstName()).isEqualTo("Maria"); } + + // ─── findCorrespondents ─────────────────────────────────────────────────── + + @Test + void findCorrespondents_returnsPersonsWhoSharedDocumentsWith() { + Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + + // Walter sends to Anna (1 document) + documentRepository.save(Document.builder() + .title("Brief 1").originalFilename("brief1.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(anna)).build()); + + // Walter sends to Clara (2 documents — Clara should rank higher) + documentRepository.save(Document.builder() + .title("Brief 2").originalFilename("brief2.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(clara)).build()); + documentRepository.save(Document.builder() + .title("Brief 3").originalFilename("brief3.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(clara)).build()); + + List correspondents = personRepository.findCorrespondents(walter.getId()); + + assertThat(correspondents).extracting(Person::getFirstName) + .containsExactly("Clara", "Anna"); // Clara ranks first (2 documents) + } + + @Test + void findCorrespondents_returnsEmpty_whenPersonHasNoDocuments() { + Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build()); + + List correspondents = personRepository.findCorrespondents(solo.getId()); + + assertThat(correspondents).isEmpty(); + } + + // ─── findCorrespondentsWithFilter ───────────────────────────────────────── + + @Test + void findCorrespondentsWithFilter_returnsOnlyMatchingCorrespondents() { + Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + Person bernd = personRepository.save(Person.builder().firstName("Bernd").lastName("Braun").build()); + + documentRepository.save(Document.builder() + .title("Brief an Anna").originalFilename("anna.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(anna)).build()); + documentRepository.save(Document.builder() + .title("Brief an Bernd").originalFilename("bernd.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(bernd)).build()); + + List filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "Anna"); + + assertThat(filtered).extracting(Person::getFirstName).containsExactly("Anna"); + } + + @Test + void findCorrespondentsWithFilter_isCaseInsensitive() { + Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + + documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(anna)).build()); + + List filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "schmidt"); + + assertThat(filtered).hasSize(1); + assertThat(filtered.get(0).getLastName()).isEqualTo("Schmidt"); + } + + // ─── reassignSender ─────────────────────────────────────────────────────── + + @Test + void reassignSender_updatesDocumentsSenderFromSourceToTarget() { + Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build()); + Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build()); + + documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .sender(source).build()); + + personRepository.reassignSender(source.getId(), target.getId()); + + List docs = documentRepository.findBySenderId(target.getId()); + assertThat(docs).hasSize(1); + assertThat(documentRepository.findBySenderId(source.getId())).isEmpty(); + } + + // ─── insertMissingReceiverReference ────────────────────────────────────── + + @Test + void insertMissingReceiverReference_addsTargetWhereSourceWasReceiver() { + Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build()); + Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build()); + Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build()); + + Document doc = documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .sender(sender).receivers(Set.of(source)).build()); + + personRepository.insertMissingReceiverReference(source.getId(), target.getId()); + + Document reloaded = documentRepository.findById(doc.getId()).orElseThrow(); + assertThat(reloaded.getReceivers()) + .extracting(Person::getId) + .contains(target.getId()); + } + + @Test + void insertMissingReceiverReference_doesNotCreateDuplicate_whenTargetAlreadyReceiver() { + Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build()); + Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build()); + Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build()); + + // target is already a receiver together with source + Document doc = documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .sender(sender).receivers(Set.of(source, target)).build()); + + personRepository.insertMissingReceiverReference(source.getId(), target.getId()); + + Document reloaded = documentRepository.findById(doc.getId()).orElseThrow(); + long targetCount = reloaded.getReceivers().stream() + .filter(p -> p.getId().equals(target.getId())).count(); + assertThat(targetCount).isEqualTo(1); // no duplicate + } + + // ─── deleteReceiverReferences ───────────────────────────────────────────── + + @Test + void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() { + Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build()); + Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build()); + + Document doc1 = documentRepository.save(Document.builder() + .title("Brief 1").originalFilename("b1.pdf") + .status(DocumentStatus.UPLOADED) + .sender(sender).receivers(Set.of(toDelete)).build()); + Document doc2 = documentRepository.save(Document.builder() + .title("Brief 2").originalFilename("b2.pdf") + .status(DocumentStatus.UPLOADED) + .sender(sender).receivers(Set.of(toDelete)).build()); + + personRepository.deleteReceiverReferences(toDelete.getId()); + + assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty(); + assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty(); + } }