diff --git a/backend/pom.xml b/backend/pom.xml
index f2dc9a8b..da863de2 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -193,8 +193,7 @@
verify
report
-
+
check
verify
@@ -207,7 +206,7 @@
BRANCH
COVEREDRATIO
- 0.42
+ 0.88
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/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java
index f316bb31..ac353a97 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java
@@ -155,4 +155,51 @@ class AnnotationControllerTest {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
+
+ // ─── resolveUserId — unauthenticated / null user / exception branches ─────
+
+ @Test
+ void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
+ // authentication == null → resolveUserId returns null
+ mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(ANNOTATION_JSON))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(authorities = "ANNOTATE_ALL")
+ void createAnnotation_resolvesNullUserId_whenUserServiceThrows() throws Exception {
+ // findByUsername throws → catch block → resolveUserId returns null
+ UUID docId = UUID.randomUUID();
+ when(userService.findByUsername(any())).thenThrow(new RuntimeException("DB error"));
+ DocumentAnnotation saved = DocumentAnnotation.builder()
+ .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
+ .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
+ when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
+ when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
+
+ mockMvc.perform(post("/api/documents/" + docId + "/annotations")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(ANNOTATION_JSON))
+ .andExpect(status().isCreated());
+ }
+
+ @Test
+ @WithMockUser(authorities = "ANNOTATE_ALL")
+ void createAnnotation_resolvesNullUserId_whenUserServiceReturnsNull() throws Exception {
+ // findByUsername returns null → user != null = false → resolveUserId returns null
+ UUID docId = UUID.randomUUID();
+ when(userService.findByUsername(any())).thenReturn(null);
+ DocumentAnnotation saved = DocumentAnnotation.builder()
+ .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
+ .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
+ when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
+ when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
+
+ mockMvc.perform(post("/api/documents/" + docId + "/annotations")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(ANNOTATION_JSON))
+ .andExpect(status().isCreated());
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java
index 40f01a39..d9c2f31d 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java
@@ -263,4 +263,20 @@ class CommentControllerTest {
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
+
+ // ─── resolveUser — exception branch ──────────────────────────────────────
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
+ // findByUsername throws → catch block in resolveUser → author null, saves anyway
+ when(userService.findByUsername(any())).thenThrow(new RuntimeException("DB error"));
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build();
+ when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
+
+ mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
+ .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
+ .andExpect(status().isCreated());
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
index 749abd88..d200c6f9 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
@@ -213,6 +213,80 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
}
+ // ─── GET /api/documents/{id}/file ────────────────────────────────────────
+
+ @Test
+ @WithMockUser
+ void getDocumentFile_returns404_whenDocHasNoFilePath() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).title("Brief").build(); // filePath == null
+ when(documentService.getDocumentById(id)).thenReturn(doc);
+
+ mockMvc.perform(get("/api/documents/" + id + "/file"))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @WithMockUser
+ void getDocumentFile_returns200_withContentTypeFromDoc() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).title("Brief")
+ .filePath("docs/brief.pdf").contentType("application/pdf")
+ .originalFilename("brief.pdf").build();
+ when(documentService.getDocumentById(id)).thenReturn(doc);
+ java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
+ when(fileService.downloadFile("docs/brief.pdf"))
+ .thenReturn(new FileService.S3FileDownload(
+ new org.springframework.core.io.InputStreamResource(stream), "application/octet-stream"));
+
+ mockMvc.perform(get("/api/documents/" + id + "/file"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @WithMockUser
+ void getDocumentFile_returns200_withContentTypeFromStorage_whenDocContentTypeIsBlank() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).title("Brief")
+ .filePath("docs/brief.pdf").contentType(" ") // blank → falls back to storage type
+ .originalFilename("brief.pdf").build();
+ when(documentService.getDocumentById(id)).thenReturn(doc);
+ java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
+ when(fileService.downloadFile("docs/brief.pdf"))
+ .thenReturn(new FileService.S3FileDownload(
+ new org.springframework.core.io.InputStreamResource(stream), "application/pdf"));
+
+ mockMvc.perform(get("/api/documents/" + id + "/file"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @WithMockUser
+ void getDocumentFile_returns404_whenStorageFileNotFound() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).title("Brief")
+ .filePath("docs/missing.pdf").contentType("application/pdf")
+ .originalFilename("missing.pdf").build();
+ when(documentService.getDocumentById(id)).thenReturn(doc);
+ when(fileService.downloadFile("docs/missing.pdf"))
+ .thenThrow(new FileService.StorageFileNotFoundException("not found"));
+
+ mockMvc.perform(get("/api/documents/" + id + "/file"))
+ .andExpect(status().isNotFound());
+ }
+
+ // ─── POST /api/documents/quick-upload — null/empty files ─────────────────
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
+ mockMvc.perform(multipart("/api/documents/quick-upload"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.created").isEmpty())
+ .andExpect(jsonPath("$.updated").isEmpty())
+ .andExpect(jsonPath("$.errors").isEmpty());
+ }
+
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
@Test
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..a56df834 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,158 @@ 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());
+ }
+
+ // ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
+ // firstName valid, lastName blank → second || operand = true → 400
+ UUID id = UUID.randomUUID();
+ mockMvc.perform(put("/api/persons/{id}", id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
+ .andExpect(status().isBadRequest());
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java
new file mode 100644
index 00000000..eb4cc3e7
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java
@@ -0,0 +1,53 @@
+package org.raddatz.familienarchiv.controller;
+
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.config.SecurityConfig;
+import org.raddatz.familienarchiv.model.AppUser;
+import org.raddatz.familienarchiv.security.PermissionAspect;
+import org.raddatz.familienarchiv.service.CustomUserDetailsService;
+import org.raddatz.familienarchiv.service.UserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.context.annotation.Import;
+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.UUID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(UserController.class)
+@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
+class UserControllerTest {
+
+ @Autowired MockMvc mockMvc;
+
+ @MockitoBean UserService userService;
+ @MockitoBean CustomUserDetailsService customUserDetailsService;
+
+ // ─── GET /api/users/me ────────────────────────────────────────────────────
+
+ @Test
+ void getCurrentUser_returns401_whenUnauthenticated() throws Exception {
+ // authentication == null → returns 401 (covers null/!isAuthenticated branch)
+ mockMvc.perform(get("/api/users/me"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(username = "anna")
+ void getCurrentUser_returns200_whenAuthenticated() throws Exception {
+ AppUser user = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
+ when(userService.findByUsername("anna")).thenReturn(user);
+
+ mockMvc.perform(get("/api/users/me"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.username").value("anna"));
+ }
+}
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..69696fdb 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java
@@ -3,14 +3,19 @@ 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;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -22,6 +27,12 @@ class PersonRepositoryTest {
@Autowired
private PersonRepository personRepository;
+ @Autowired
+ private DocumentRepository documentRepository;
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
// ─── save and findById ────────────────────────────────────────────────────
@Test
@@ -133,4 +144,171 @@ 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());
+ entityManager.flush();
+ entityManager.clear();
+
+ 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());
+ entityManager.flush();
+ entityManager.clear();
+
+ 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());
+ entityManager.flush();
+ entityManager.clear();
+
+ 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());
+ entityManager.flush();
+ entityManager.clear();
+
+ assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
+ assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java
index 6337052a..2605cfb1 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java
@@ -183,4 +183,100 @@ class AnnotationServiceTest {
verify(annotationRepository, never()).save(any());
}
+
+ // ─── deleteAnnotation — null userId ───────────────────────────────────────
+
+ @Test
+ void deleteAnnotation_throwsForbidden_whenUserIdIsNull() {
+ UUID docId = UUID.randomUUID();
+ UUID annotId = UUID.randomUUID();
+ UUID ownerId = UUID.randomUUID();
+
+ DocumentAnnotation annotation = DocumentAnnotation.builder()
+ .id(annotId).documentId(docId).createdBy(ownerId).build();
+ when(annotationRepository.findByIdAndDocumentId(annotId, docId))
+ .thenReturn(Optional.of(annotation));
+
+ assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, null))
+ .isInstanceOf(DomainException.class)
+ .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
+ }
+
+ // ─── overlaps — partial overlap cases ────────────────────────────────────
+
+ @Test
+ void createAnnotation_noConflict_whenAnnotationIsToTheLeft() {
+ // existing: x=0.5, w=0.3 (x2=0.8); dto: x=0.0, w=0.4 (dx2=0.4)
+ // existing.getX() < dx2 → 0.5 < 0.4 → FALSE → no overlap (first && fails)
+ UUID docId = UUID.randomUUID();
+ DocumentAnnotation existing = DocumentAnnotation.builder()
+ .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
+ .x(0.5).y(0.0).width(0.3).height(0.5).color("#ff0000").build();
+ CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.4, 0.5, "#0000ff");
+ when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
+ when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
+
+ verify(annotationRepository).save(any());
+ }
+
+ @Test
+ void createAnnotation_noConflict_whenAnnotationIsToTheRight() {
+ // existing: x=0.0, w=0.1 (ex2=0.1); dto: x=0.2, w=0.3 (dx2=0.5)
+ // existing.getX() < dx2 → 0.0 < 0.5 → TRUE
+ // ex2 > dto.getX() → 0.1 > 0.2 → FALSE → no overlap (second && fails)
+ UUID docId = UUID.randomUUID();
+ DocumentAnnotation existing = DocumentAnnotation.builder()
+ .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
+ .x(0.0).y(0.0).width(0.1).height(0.5).color("#ff0000").build();
+ CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.2, 0.0, 0.3, 0.5, "#0000ff");
+ when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
+ when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
+
+ verify(annotationRepository).save(any());
+ }
+
+ @Test
+ void createAnnotation_noConflict_whenAnnotationIsBelow() {
+ // x ranges overlap, but y ranges don't
+ // existing: x=0.0, w=0.5, y=0.5, h=0.2 (ey2=0.7)
+ // dto: x=0.1, w=0.3 (dx2=0.4), y=0.0, h=0.4 (dy2=0.4)
+ // existing.getX() < dx2 → 0.0 < 0.4 → TRUE
+ // ex2 > dto.getX() → 0.5 > 0.1 → TRUE
+ // existing.getY() < dy2 → 0.5 < 0.4 → FALSE → no overlap (third && fails)
+ UUID docId = UUID.randomUUID();
+ DocumentAnnotation existing = DocumentAnnotation.builder()
+ .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
+ .x(0.0).y(0.5).width(0.5).height(0.2).color("#ff0000").build();
+ CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.0, 0.3, 0.4, "#0000ff");
+ when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
+ when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
+
+ verify(annotationRepository).save(any());
+ }
+
+ @Test
+ void createAnnotation_noConflict_whenAnnotationIsAbove() {
+ // x ranges overlap, y ranges don't — existing is ABOVE the new annotation
+ // existing: x=0.0, w=0.5, y=0.0, h=0.1 (ey2=0.1)
+ // dto: x=0.1, w=0.3 (dx2=0.4), y=0.2, h=0.3 (dy2=0.5)
+ // A: 0.0 < 0.4 → TRUE, B: 0.5 > 0.1 → TRUE, C: 0.0 < 0.5 → TRUE
+ // D: ey2 > dto.getY() → 0.1 > 0.2 → FALSE → no overlap (fourth && fails)
+ UUID docId = UUID.randomUUID();
+ DocumentAnnotation existing = DocumentAnnotation.builder()
+ .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
+ .x(0.0).y(0.0).width(0.5).height(0.1).color("#ff0000").build();
+ CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.2, 0.3, 0.3, "#0000ff");
+ when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
+ when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
+
+ verify(annotationRepository).save(any());
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java
index 6d3e8abe..8373f110 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java
@@ -300,6 +300,181 @@ class CommentServiceTest {
assertThat(result.get(0).getReplies()).containsExactly(reply);
}
+ // ─── replyToComment — reply with null authorId in thread ─────────────────
+
+ @Test
+ void replyToComment_handlesNullAuthorId_inExistingReply() {
+ UUID docId = UUID.randomUUID();
+ UUID rootId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").firstName("Anna").lastName("S").build();
+
+ DocumentComment root = DocumentComment.builder()
+ .id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID()).content("Root").authorName("Root").build();
+ // Existing reply with null authorId
+ DocumentComment nullAuthorReply = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorId(null).content("Anon reply").authorName("anon").build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("New reply").authorName("Anna S").build();
+
+ when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
+ when(commentRepository.findByParentId(rootId)).thenReturn(List.of(nullAuthorReply));
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ commentService.replyToComment(docId, rootId, "New reply", List.of(), author);
+
+ // Must not throw NullPointerException; only non-null authorIds collected
+ verify(notificationService).notifyReply(eq(saved), anySet());
+ }
+
+ // ─── resolveAuthorName edge cases ─────────────────────────────────────────
+
+ @Test
+ void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
+ UUID docId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
+ .firstName(" ").lastName(null).build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
+
+ assertThat(result.getAuthorName()).isEqualTo("user42");
+ }
+
+ @Test
+ void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
+ UUID docId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
+ .firstName(null).lastName(" ").build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
+
+ assertThat(result.getAuthorName()).isEqualTo("user42");
+ }
+
+ @Test
+ void postComment_includesOnlyFirstName_whenLastNameIsNull() {
+ UUID docId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
+ .firstName("Hans").lastName(null).build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ commentService.postComment(docId, null, "Hi", List.of(), author);
+
+ // first != null && !blank → true; last == null → entire condition false → returns stripped first
+ verify(commentRepository).save(any());
+ }
+
+ @Test
+ void postComment_includesOnlyLastName_whenFirstNameIsNull() {
+ UUID docId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
+ .firstName(null).lastName("Müller").build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ commentService.postComment(docId, null, "Hi", List.of(), author);
+
+ // No exception — name resolution with null first name strips cleanly
+ verify(commentRepository).save(any());
+ }
+
+ // ─── saveMentions — null/empty guard ─────────────────────────────────────
+
+ @Test
+ void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
+ UUID docId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans")
+ .firstName("Hans").lastName("M").build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ commentService.postComment(docId, null, "Hi", null, author);
+
+ verify(userService, never()).findAllById(anyList());
+ }
+
+ // ─── collectParticipantIds — non-null authorId in reply ──────────────────
+
+ @Test
+ void replyToComment_includesNonNullAuthorId_fromExistingReply() {
+ UUID docId = UUID.randomUUID();
+ UUID rootId = UUID.randomUUID();
+ UUID existingReplyAuthorId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
+
+ DocumentComment root = DocumentComment.builder()
+ .id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID())
+ .content("Root").authorName("root").build();
+ // Existing reply WITH a non-null authorId — covers true branch of reply.getAuthorId() != null
+ DocumentComment existingReply = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).parentId(rootId)
+ .authorId(existingReplyAuthorId).content("Existing").authorName("someone").build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).parentId(rootId)
+ .content("New reply").authorName("anna").build();
+
+ when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
+ when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ commentService.replyToComment(docId, rootId, "New reply", List.of(), author);
+
+ verify(notificationService).notifyReply(eq(saved), anySet());
+ }
+
+ // ─── collectParticipantIds — null authorId ────────────────────────────────
+
+ @Test
+ void replyToComment_excludesNullAuthorIds_fromParticipantSet() {
+ UUID docId = UUID.randomUUID();
+ UUID rootId = UUID.randomUUID();
+ AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
+
+ // Root with null authorId
+ DocumentComment root = DocumentComment.builder()
+ .id(rootId).documentId(docId).parentId(null).authorId(null).content("Root").authorName("anon").build();
+ DocumentComment saved = DocumentComment.builder()
+ .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
+
+ when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
+ when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
+ when(commentRepository.save(any())).thenReturn(saved);
+
+ // Must not throw NullPointerException
+ commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
+
+ verify(notificationService).notifyReply(eq(saved), anySet());
+ }
+
+ // ─── getCommentsForAnnotation ─────────────────────────────────────────────
+
+ @Test
+ void getCommentsForAnnotation_returnsRootsForAnnotation() {
+ UUID annotationId = UUID.randomUUID();
+ UUID rootId = UUID.randomUUID();
+
+ DocumentComment root = DocumentComment.builder()
+ .id(rootId).annotationId(annotationId).authorName("Hans").content("Root").build();
+
+ when(commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId))
+ .thenReturn(List.of(root));
+ when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
+
+ List result = commentService.getCommentsForAnnotation(annotationId);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId);
+ }
+
// ─── helpers ──────────────────────────────────────────────────────────────
private AppUser buildAdmin() {
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CustomUserDetailsServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CustomUserDetailsServiceTest.java
new file mode 100644
index 00000000..8033faf2
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CustomUserDetailsServiceTest.java
@@ -0,0 +1,120 @@
+package org.raddatz.familienarchiv.service;
+
+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.model.AppUser;
+import org.raddatz.familienarchiv.model.UserGroup;
+import org.raddatz.familienarchiv.repository.AppUserRepository;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class CustomUserDetailsServiceTest {
+
+ @Mock AppUserRepository userRepository;
+ @InjectMocks CustomUserDetailsService service;
+
+ // ─── loadUserByUsername — not found ──────────────────────────────────────
+
+ @Test
+ void loadUserByUsername_throwsUsernameNotFoundException_whenUserNotFound() {
+ when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.loadUserByUsername("ghost"))
+ .isInstanceOf(UsernameNotFoundException.class)
+ .hasMessageContaining("ghost");
+ }
+
+ // ─── loadUserByUsername — happy path ─────────────────────────────────────
+
+ @Test
+ void loadUserByUsername_returnsUserDetails_withMappedAuthorities() {
+ UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins")
+ .permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
+ AppUser user = AppUser.builder().id(UUID.randomUUID())
+ .username("admin").password("hashed").enabled(true)
+ .groups(Set.of(group)).build();
+ when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
+
+ UserDetails details = service.loadUserByUsername("admin");
+
+ assertThat(details.getUsername()).isEqualTo("admin");
+ assertThat(details.getAuthorities()).extracting("authority")
+ .contains("READ_ALL", "WRITE_ALL");
+ }
+
+ @Test
+ void loadUserByUsername_returnsEmptyAuthorities_whenUserHasNoGroups() {
+ AppUser user = AppUser.builder().id(UUID.randomUUID())
+ .username("viewer").password("hashed").enabled(true)
+ .groups(Set.of()).build();
+ when(userRepository.findByUsername("viewer")).thenReturn(Optional.of(user));
+
+ UserDetails details = service.loadUserByUsername("viewer");
+
+ assertThat(details.getAuthorities()).isEmpty();
+ }
+
+ // ─── loadUserByUsername — unknown permission ──────────────────────────────
+
+ @Test
+ void loadUserByUsername_grantsUnknownPermission_butLogsWarning() {
+ // Unknown permissions should still be granted (logged as warning, not silently dropped)
+ UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("CustomGroup")
+ .permissions(Set.of("UNKNOWN_CUSTOM_PERM")).build();
+ AppUser user = AppUser.builder().id(UUID.randomUUID())
+ .username("custom").password("hashed").enabled(true)
+ .groups(Set.of(group)).build();
+ when(userRepository.findByUsername("custom")).thenReturn(Optional.of(user));
+
+ UserDetails details = service.loadUserByUsername("custom");
+
+ assertThat(details.getAuthorities()).extracting("authority")
+ .contains("UNKNOWN_CUSTOM_PERM");
+ }
+
+ // ─── loadUserByUsername — disabled user ───────────────────────────────────
+
+ @Test
+ void loadUserByUsername_returnsDisabledUser_whenUserIsDisabled() {
+ AppUser user = AppUser.builder().id(UUID.randomUUID())
+ .username("disabled").password("hashed").enabled(false)
+ .groups(Set.of()).build();
+ when(userRepository.findByUsername("disabled")).thenReturn(Optional.of(user));
+
+ UserDetails details = service.loadUserByUsername("disabled");
+
+ assertThat(details.isEnabled()).isFalse();
+ }
+
+ // ─── loadUserByUsername — multi-group permission merge ────────────────────
+
+ @Test
+ void loadUserByUsername_mergesPermissionsFromMultipleGroups() {
+ UserGroup g1 = UserGroup.builder().id(UUID.randomUUID()).name("Readers")
+ .permissions(Set.of("READ_ALL")).build();
+ UserGroup g2 = UserGroup.builder().id(UUID.randomUUID()).name("Writers")
+ .permissions(Set.of("WRITE_ALL")).build();
+ AppUser user = AppUser.builder().id(UUID.randomUUID())
+ .username("multi").password("hashed").enabled(true)
+ .groups(Set.of(g1, g2)).build();
+ when(userRepository.findByUsername("multi")).thenReturn(Optional.of(user));
+
+ UserDetails details = service.loadUserByUsername("multi");
+
+ assertThat(details.getAuthorities()).extracting("authority")
+ .containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java
index c02fb358..80efd3e4 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java
@@ -670,4 +670,503 @@ class DocumentServiceTest {
void titleFromFilename_null_returnsNull() {
assertThat(DocumentService.titleFromFilename(null)).isNull();
}
+
+ // ─── titleFromFilename — tryParseDate invalid cases ───────────────────────
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidMonth() {
+ // 1965-13-12 → month 13 is invalid → tryParseDate returns null → fallback
+ assertThat(DocumentService.titleFromFilename("1965-13-12_Mueller_Hans.pdf"))
+ .isEqualTo("1965-13-12_Mueller_Hans");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidDay() {
+ // 1965-03-00 → day 0 is invalid → tryParseDate returns null → fallback
+ assertThat(DocumentService.titleFromFilename("1965-03-00_Mueller_Hans.pdf"))
+ .isEqualTo("1965-03-00_Mueller_Hans");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidMonth() {
+ // 19651312 → month 13 → invalid
+ assertThat(DocumentService.titleFromFilename("19651312_Mueller_Hans.pdf"))
+ .isEqualTo("19651312_Mueller_Hans");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidDay() {
+ // 19650300 → day 0 → invalid
+ assertThat(DocumentService.titleFromFilename("19650300_Mueller_Hans.pdf"))
+ .isEqualTo("19650300_Mueller_Hans");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenStemHasNoExtension() {
+ // No dot → parseFilenameData returns null → titleFromFilename returns null? No,
+ // actually it returns null when filename is null, otherwise stripExtension is called.
+ // Without a dot, dot = -1, strip returns the whole string.
+ assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312"))
+ .isEqualTo("Mueller_Hans_19650312");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenNamePartsContainNonLetters() {
+ // Parts with numbers/hyphens fail the \p{L}+ regex → returns null → fallback
+ assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_H4ns.pdf"))
+ .isEqualTo("1965-03-12_Mueller_H4ns");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenOnlyTwoParts() {
+ // "1965-03-12_Mueller.pdf" → less than 2 name parts → null → fallback
+ assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller.pdf"))
+ .isEqualTo("1965-03-12_Mueller");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenIsoDateHasMonthZero() {
+ // 1965-00-12 → month 0 → m >= 1 is false → tryParseDate returns null
+ assertThat(DocumentService.titleFromFilename("1965-00-12_Mueller_Hans.pdf"))
+ .isEqualTo("1965-00-12_Mueller_Hans");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenIsoDateHasDayAbove31() {
+ // 1965-03-32 → day 32 > 31 → d <= 31 is false → tryParseDate returns null
+ assertThat(DocumentService.titleFromFilename("1965-03-32_Mueller_Hans.pdf"))
+ .isEqualTo("1965-03-32_Mueller_Hans");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenCompactDateHasMonthZero() {
+ // 19650012 → month 0 → m >= 1 is false
+ assertThat(DocumentService.titleFromFilename("19650012_Mueller_Hans.pdf"))
+ .isEqualTo("19650012_Mueller_Hans");
+ }
+
+ @Test
+ void titleFromFilename_returnsStrippedName_whenCompactDateHasDayAbove31() {
+ // 19650332 → day 32 > 31
+ assertThat(DocumentService.titleFromFilename("19650332_Mueller_Hans.pdf"))
+ .isEqualTo("19650332_Mueller_Hans");
+ }
+
+ // ─── getConversationFiltered ───────────────────────────────────────────────
+
+ @Test
+ void getConversationFiltered_passesGivenDates_whenFromAndToAreProvided() {
+ UUID senderId = UUID.randomUUID();
+ UUID receiverId = UUID.randomUUID();
+ LocalDate from = LocalDate.of(1940, 1, 1);
+ LocalDate to = LocalDate.of(1960, 12, 31);
+ Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
+ when(documentRepository.findConversation(senderId, receiverId, from, to, sort))
+ .thenReturn(List.of());
+
+ documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
+
+ verify(documentRepository).findConversation(senderId, receiverId, from, to, sort);
+ }
+
+ @Test
+ void getConversationFiltered_usesMinDateForFrom_whenFromIsNull() {
+ UUID senderId = UUID.randomUUID();
+ UUID receiverId = UUID.randomUUID();
+ Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
+ when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
+ .thenReturn(List.of());
+
+ documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
+
+ ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDate.class);
+ verify(documentRepository).findConversation(eq(senderId), eq(receiverId), fromCaptor.capture(), any(LocalDate.class), eq(sort));
+ assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.parse("0000-01-01"));
+ }
+
+ @Test
+ void getConversationFiltered_usesTodayForTo_whenToIsNull() {
+ UUID senderId = UUID.randomUUID();
+ UUID receiverId = UUID.randomUUID();
+ Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
+ when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
+ .thenReturn(List.of());
+
+ documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
+
+ ArgumentCaptor toCaptor = ArgumentCaptor.forClass(LocalDate.class);
+ verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), toCaptor.capture(), eq(sort));
+ assertThat(toCaptor.getValue()).isEqualTo(LocalDate.now());
+ }
+
+ // ─── updateDocumentTags — empty tag in list ───────────────────────────────
+
+ @Test
+ void updateDocumentTags_skipsEmptyTagNames() {
+ UUID id = UUID.randomUUID();
+ Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
+ Document doc = Document.builder().id(id).title("T").build();
+
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(tagService.findOrCreate("Familie")).thenReturn(tag);
+
+ // List with empty string element — cleanName.isEmpty() branch hit
+ documentService.updateDocumentTags(id, List.of("Familie", " ", ""));
+
+ verify(tagService).findOrCreate("Familie");
+ verify(tagService, times(1)).findOrCreate(any()); // only "Familie" — others skipped
+ }
+
+ // ─── createDocument — with empty tag segment ──────────────────────────────
+
+ @Test
+ void createDocument_filtersEmptyTagSegments() throws Exception {
+ UUID docId = UUID.randomUUID();
+ Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("Test");
+ dto.setTags("Familie, ,"); // middle and trailing blank segments
+
+ when(documentRepository.save(any())).thenAnswer(inv -> {
+ Document d = inv.getArgument(0);
+ if (d.getId() == null) {
+ return Document.builder().id(docId).title(d.getTitle()).build();
+ }
+ return d;
+ });
+ when(documentRepository.findById(docId)).thenReturn(Optional.of(
+ Document.builder().id(docId).title("Test").build()));
+ when(tagService.findOrCreate("Familie")).thenReturn(tag);
+
+ documentService.createDocument(dto, null);
+
+ verify(tagService).findOrCreate("Familie");
+ verify(tagService, times(1)).findOrCreate(any());
+ }
+
+ // ─── createDocument — with sender and receivers ───────────────────────────
+
+ @Test
+ void createDocument_setsSender_whenSenderIdIsProvided() throws Exception {
+ UUID senderId = UUID.randomUUID();
+ Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
+ UUID docId = UUID.randomUUID();
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("Test");
+ dto.setSenderId(senderId);
+
+ when(documentRepository.save(any())).thenAnswer(inv -> {
+ Document d = inv.getArgument(0);
+ if (d.getId() == null) {
+ Document saved = Document.builder().id(docId).title(d.getTitle()).build();
+ return saved;
+ }
+ return d;
+ });
+ when(documentRepository.findById(docId)).thenReturn(Optional.of(
+ Document.builder().id(docId).title("Test").build()));
+ when(personService.getById(senderId)).thenReturn(sender);
+
+ documentService.createDocument(dto, null);
+
+ verify(personService).getById(senderId);
+ }
+
+ @Test
+ void createDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
+ UUID r1Id = UUID.randomUUID();
+ UUID r2Id = UUID.randomUUID();
+ UUID docId = UUID.randomUUID();
+ Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
+ Person r2 = Person.builder().id(r2Id).firstName("C").lastName("D").build();
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("Test");
+ dto.setReceiverIds(List.of(r1Id, r2Id));
+
+ when(documentRepository.save(any())).thenAnswer(inv -> {
+ Document d = inv.getArgument(0);
+ if (d.getId() == null) {
+ return Document.builder().id(docId).title(d.getTitle()).build();
+ }
+ return d;
+ });
+ when(documentRepository.findById(docId)).thenReturn(Optional.of(
+ Document.builder().id(docId).title("Test").build()));
+ when(personService.getAllById(List.of(r1Id, r2Id))).thenReturn(List.of(r1, r2));
+
+ documentService.createDocument(dto, null);
+
+ verify(personService).getAllById(List.of(r1Id, r2Id));
+ }
+
+ // ─── createDocument — empty file fallback and blank tags ─────────────────
+
+ @Test
+ void createDocument_usesUnbenanntesDocument_whenFileIsEmptyAndTitleIsNull() throws Exception {
+ // file != null but isEmpty() = true → falls through to title ternary
+ // title == null → "Unbenanntes Dokument"
+ MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
+ DocumentUpdateDTO dto = new DocumentUpdateDTO(); // title = null
+
+ Document saved = Document.builder().id(UUID.randomUUID()).title("Unbenanntes Dokument")
+ .originalFilename("Unbenanntes Dokument").status(DocumentStatus.PLACEHOLDER).build();
+ when(documentRepository.save(any())).thenReturn(saved);
+ when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class);
+ documentService.createDocument(dto, emptyFile);
+
+ verify(documentRepository, atLeastOnce()).save(captor.capture());
+ assertThat(captor.getAllValues().get(0).getOriginalFilename()).isEqualTo("Unbenanntes Dokument");
+ }
+
+ @Test
+ void createDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("Doc");
+ dto.setTags(" "); // not null but blank → condition false
+
+ Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
+ .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
+ when(documentRepository.save(any())).thenReturn(saved);
+ when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
+
+ documentService.createDocument(dto, null);
+
+ verify(tagService, never()).findOrCreate(any());
+ }
+
+ @Test
+ void createDocument_setsMetadataCompleteFalse_whenReceiverIdsIsEmptyList() throws Exception {
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("Doc");
+ dto.setReceiverIds(List.of()); // not null but empty → !isEmpty() = false → false
+
+ Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
+ .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
+ when(documentRepository.save(any())).thenReturn(saved);
+ when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class);
+ documentService.createDocument(dto, null);
+
+ verify(documentRepository, atLeastOnce()).save(captor.capture());
+ assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
+ }
+
+ // ─── updateDocument — empty file, blank tags, empty receivers ────────────
+
+ @Test
+ void updateDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+ dto.setTags(" "); // not null but blank
+
+ documentService.updateDocument(id, dto, null);
+
+ verify(tagService, never()).findOrCreate(any());
+ }
+
+ @Test
+ void updateDocument_clearsReceivers_whenReceiverIdsIsEmptyList() throws Exception {
+ UUID id = UUID.randomUUID();
+ Person r1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
+ Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>(Set.of(r1))).build();
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+ dto.setReceiverIds(List.of()); // not null but empty → else → clear
+
+ documentService.updateDocument(id, dto, null);
+
+ assertThat(doc.getReceivers()).isEmpty();
+ }
+
+ @Test
+ void updateDocument_skipsFileUpload_whenNewFileIsEmpty() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
+ MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+
+ documentService.updateDocument(id, dto, emptyFile);
+
+ verify(fileService, never()).uploadFile(any(), any());
+ }
+
+ // ─── titleFromFilename — no date in any position ──────────────────────────
+
+ @Test
+ void titleFromFilename_returnsStripped_whenNeitherFirstNorLastPartIsDate() {
+ // "Mueller_Hans_Schmitt.pdf" → 3 parts, none is a date → dateFromLast == null → null → stripExtension
+ assertThat(DocumentService.titleFromFilename("Mueller_Hans_Schmitt.pdf"))
+ .isEqualTo("Mueller_Hans_Schmitt");
+ }
+
+ @Test
+ void updateDocument_setsTags_withEmptySegmentsFiltered() throws Exception {
+ // Tags string with blank segment: "Familie, ,Reise" → only "Familie" and "Reise" used
+ UUID id = UUID.randomUUID();
+ Tag t1 = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
+ Tag t2 = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
+ Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
+
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(tagService.findOrCreate("Familie")).thenReturn(t1);
+ when(tagService.findOrCreate("Reise")).thenReturn(t2);
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+ dto.setTags("Familie, ,Reise"); // blank middle segment filtered
+
+ documentService.updateDocument(id, dto, null);
+
+ verify(tagService).findOrCreate("Familie");
+ verify(tagService).findOrCreate("Reise");
+ verify(tagService, times(2)).findOrCreate(any());
+ }
+
+ @Test
+ void createDocument_setsTags_whenTagsStringIsProvided() throws Exception {
+ UUID docId = UUID.randomUUID();
+ Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("Test");
+ dto.setTags("Familie");
+
+ when(documentRepository.save(any())).thenAnswer(inv -> {
+ Document d = inv.getArgument(0);
+ if (d.getId() == null) {
+ return Document.builder().id(docId).title(d.getTitle()).build();
+ }
+ return d;
+ });
+ when(documentRepository.findById(docId)).thenReturn(Optional.of(
+ Document.builder().id(docId).title("Test").build()));
+ when(tagService.findOrCreate("Familie")).thenReturn(tag);
+
+ documentService.createDocument(dto, null);
+
+ verify(tagService).findOrCreate("Familie");
+ }
+
+ // ─── updateDocument — with sender / clear receivers ──────────────────────
+
+ @Test
+ void updateDocument_clearsSender_whenSenderIdIsNull() throws Exception {
+ UUID id = UUID.randomUUID();
+ Person existingSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
+ Document doc = Document.builder().id(id).title("T").sender(existingSender).receivers(new HashSet<>()).build();
+
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); // also for updateDocumentTags
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+ // senderId is null — should clear sender
+
+ documentService.updateDocument(id, dto, null);
+
+ verify(documentRepository, atLeastOnce()).save(argThat(d -> d.getSender() == null));
+ }
+
+ @Test
+ void updateDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID r1Id = UUID.randomUUID();
+ Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
+ Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
+
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(personService.getAllById(List.of(r1Id))).thenReturn(List.of(r1));
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+ dto.setReceiverIds(List.of(r1Id));
+
+ documentService.updateDocument(id, dto, null);
+
+ verify(personService).getAllById(List.of(r1Id));
+ }
+
+ @Test
+ void updateDocument_setsTags_whenTagsStringIsProvided() throws Exception {
+ UUID id = UUID.randomUUID();
+ Tag tag = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
+ Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
+
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(tagService.findOrCreate("Reise")).thenReturn(tag);
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+ dto.setTags("Reise");
+
+ documentService.updateDocument(id, dto, null);
+
+ verify(tagService).findOrCreate("Reise");
+ }
+
+ @Test
+ void updateDocument_setsSender_whenSenderIdIsProvided() throws Exception {
+ // dto.getSenderId() != null → true branch: sets sender via personService
+ UUID id = UUID.randomUUID();
+ UUID senderId = UUID.randomUUID();
+ Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
+ Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(personService.getById(senderId)).thenReturn(sender);
+
+ DocumentUpdateDTO dto = new DocumentUpdateDTO();
+ dto.setTitle("T");
+ dto.setSenderId(senderId);
+
+ documentService.updateDocument(id, dto, null);
+
+ verify(personService).getById(senderId);
+ assertThat(doc.getSender()).isEqualTo(sender);
+ }
+
+ // ─── stripExtension / parseFilenameData — null guard branches ────────────
+
+ @Test
+ void stripExtension_returnsNull_whenFilenameIsNull() throws Exception {
+ // filename == null = true → null guard branch in private static method
+ java.lang.reflect.Method method = DocumentService.class
+ .getDeclaredMethod("stripExtension", String.class);
+ method.setAccessible(true);
+ String result = (String) method.invoke(null, (String) null);
+ assertThat(result).isNull();
+ }
+
+ @Test
+ void parseFilenameData_returnsNull_whenFilenameIsNull() throws Exception {
+ // filename == null = true → null guard branch in private static method
+ java.lang.reflect.Method method = DocumentService.class
+ .getDeclaredMethod("parseFilenameData", String.class);
+ method.setAccessible(true);
+ Object result = method.invoke(null, (String) null);
+ assertThat(result).isNull();
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java
index 90b20dd9..cff939ee 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java
@@ -374,6 +374,366 @@ class DocumentVersionServiceTest {
assertThat(count).isEqualTo(2);
}
+ // ─── recordVersion — no auth / user not found ─────────────────────────────
+
+ @Test
+ void recordVersion_usesUnknown_whenSecurityContextHasNoAuthentication() {
+ // No call to authenticateAs — context is cleared
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.recordVersion(minimalDocument());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
+ assertThat(captor.getValue().getEditorId()).isNull();
+ }
+
+ @Test
+ void recordVersion_usesUnknown_whenAuthenticationIsNotAuthenticated() {
+ // Auth present but isAuthenticated() = false — use TestingAuthenticationToken
+ org.springframework.security.authentication.TestingAuthenticationToken notAuth =
+ new org.springframework.security.authentication.TestingAuthenticationToken("user", null);
+ notAuth.setAuthenticated(false);
+ SecurityContextHolder.getContext().setAuthentication(notAuth);
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.recordVersion(minimalDocument());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
+ }
+
+ @Test
+ void recordVersion_usesUnknown_whenUserServiceThrows() {
+ authenticateAs("missinguser");
+ when(userService.findByUsername("missinguser")).thenThrow(new RuntimeException("not found"));
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.recordVersion(minimalDocument());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
+ }
+
+ // ─── recordVersion — buildEditorName edge cases ───────────────────────────
+
+ @Test
+ void recordVersion_usesUsername_whenFirstNameIsNotBlankButLastNameIsNull() {
+ authenticateAs("user42");
+ when(userService.findByUsername("user42")).thenReturn(
+ AppUser.builder().id(UUID.randomUUID()).username("user42")
+ .firstName("Hans").lastName(null).build());
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.recordVersion(minimalDocument());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
+ }
+
+ @Test
+ void recordVersion_usesUsername_whenFirstNameIsBlankButLastNameIsPresent() {
+ authenticateAs("user42");
+ when(userService.findByUsername("user42")).thenReturn(
+ AppUser.builder().id(UUID.randomUUID()).username("user42")
+ .firstName(" ").lastName("Müller").build());
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.recordVersion(minimalDocument());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
+ }
+
+ @Test
+ void recordVersion_usesUsername_whenLastNameIsBlankButFirstNameIsPresent() {
+ authenticateAs("user42");
+ when(userService.findByUsername("user42")).thenReturn(
+ AppUser.builder().id(UUID.randomUUID()).username("user42")
+ .firstName("Hans").lastName(" ").build());
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.recordVersion(minimalDocument());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
+ }
+
+ // ─── recordVersion — computeChangedFields with corrupt snapshot ──────────
+
+ @Test
+ void recordVersion_returnsEmptyChangedFields_whenPreviousSnapshotIsInvalidJson() {
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ UUID docId = UUID.randomUUID();
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot("INVALID JSON")
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.recordVersion(Document.builder().id(docId).title("T").build());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
+ }
+
+ // ─── recordVersion — checkSender/checkReceivers/checkTags with no previous ─
+
+ @Test
+ void recordVersion_tracksSenderAdded_whenPreviousHadNoSender() throws Exception {
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ ObjectMapper mapper = new ObjectMapper();
+ UUID docId = UUID.randomUUID();
+ Document oldDoc = Document.builder().id(docId).title("T").build(); // no sender
+ String oldSnapshot = mapper.writeValueAsString(oldDoc);
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Person newSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
+ Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).contains("sender");
+ }
+
+ @Test
+ void recordVersion_tracksReceiversAdded_whenPreviousHadNone() throws Exception {
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ ObjectMapper mapper = new ObjectMapper();
+ UUID docId = UUID.randomUUID();
+ Document oldDoc = Document.builder().id(docId).title("T").build(); // no receivers
+ String oldSnapshot = mapper.writeValueAsString(oldDoc);
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Person r = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build();
+ Document updated = Document.builder().id(docId).title("T").receivers(Set.of(r)).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).contains("receivers");
+ }
+
+ @Test
+ void recordVersion_tracksTagsAdded_whenPreviousHadNone() throws Exception {
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ ObjectMapper mapper = new ObjectMapper();
+ UUID docId = UUID.randomUUID();
+ Document oldDoc = Document.builder().id(docId).title("T").build(); // no tags
+ String oldSnapshot = mapper.writeValueAsString(oldDoc);
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
+ Document updated = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).contains("tags");
+ }
+
+ // ─── checkSender — sender map with null id ───────────────────────────────
+
+ @Test
+ void recordVersion_senderChangedToPresent_whenPreviousSenderHasNullId() throws Exception {
+ // Covers: prevSender instanceof Map = true, but id == null → prevId = null
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ UUID docId = UUID.randomUUID();
+ // Manually craft a JSON where sender object exists but id is null
+ String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\","
+ + "\"sender\":{\"id\":null,\"firstName\":\"A\",\"lastName\":\"B\"},"
+ + "\"receivers\":[],\"tags\":[]}";
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Person newSender = Person.builder().id(UUID.randomUUID()).firstName("B").lastName("C").build();
+ Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).contains("sender");
+ }
+
+ // ─── checkSender — sender unchanged → not in changedFields ───────────────
+
+ @Test
+ void recordVersion_doesNotTrackSender_whenSenderUnchanged() throws Exception {
+ // Covers: !Objects.equals(currentId, prevId) = false → don't add "sender"
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ ObjectMapper mapper = new ObjectMapper();
+ UUID docId = UUID.randomUUID();
+ UUID senderId = UUID.randomUUID();
+ Person sender = Person.builder().id(senderId).firstName("A").lastName("B").build();
+ Document oldDoc = Document.builder().id(docId).title("T").sender(sender).build();
+ String oldSnapshot = mapper.writeValueAsString(oldDoc);
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ // Same sender — should NOT be in changedFields
+ Document updated = Document.builder().id(docId).title("T").sender(sender).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).doesNotContain("sender");
+ }
+
+ // ─── computeChangedFields — documentDate ternary true branch ─────────────
+
+ @Test
+ void recordVersion_tracksDocumentDate_whenCurrentDocHasNonNullDate() throws Exception {
+ // current.getDocumentDate() != null = true → ternary true branch in computeChangedFields
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ ObjectMapper mapper = new ObjectMapper();
+ UUID docId = UUID.randomUUID();
+ Document oldDoc = Document.builder().id(docId).title("T").build(); // no date in previous
+ String oldSnapshot = mapper.writeValueAsString(oldDoc);
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ // Current doc has a non-null documentDate → ternary evaluates its true branch
+ Document updated = Document.builder().id(docId).title("T")
+ .documentDate(LocalDate.of(1965, 3, 12)).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).contains("documentDate");
+ }
+
+ // ─── checkReceivers / checkTags — when previous snapshot has null values ───
+
+ @Test
+ void recordVersion_tracksReceivers_whenPreviousSnapshotHasNullReceivers() throws Exception {
+ // prevReceivers NOT instanceof List> → prevIds = Set.of() → if currentIds differ → added
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ UUID docId = UUID.randomUUID();
+ // Craft snapshot where "receivers" is JSON null → deserialized as null, NOT a List
+ String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\",\"receivers\":null,\"tags\":[]}";
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Person r = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
+ Document updated = Document.builder().id(docId).title("T").receivers(Set.of(r)).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).contains("receivers");
+ }
+
+ @Test
+ void recordVersion_tracksTags_whenPreviousSnapshotHasNullTags() throws Exception {
+ // prevTags NOT instanceof List> → prevNames = Set.of() → if currentNames differ → added
+ authenticateAs("user1");
+ when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
+
+ UUID docId = UUID.randomUUID();
+ // Craft snapshot where "tags" is JSON null → deserialized as null, NOT a List
+ String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\",\"receivers\":[],\"tags\":null}";
+
+ DocumentVersion previous = DocumentVersion.builder()
+ .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
+ .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
+ .editorName("user1").build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
+ Document updated = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
+ versionService.recordVersion(updated);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getChangedFields()).contains("tags");
+ }
+
+ // ─── backfill — uses LocalDateTime.now() when createdAt is null ──────────
+
+ @Test
+ void backfill_usesNow_whenDocumentCreatedAtIsNull() {
+ Document doc = Document.builder().id(UUID.randomUUID()).title("T").createdAt(null).build();
+ when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
+ when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ versionService.backfillMissingVersions(List.of(doc));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class);
+ verify(versionRepository).save(captor.capture());
+ assertThat(captor.getValue().getSavedAt()).isNotNull();
+ }
+
// ─── helpers ──────────────────────────────────────────────────────────────
private void authenticateAs(String username) {
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java
index d8f719eb..187c144e 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java
@@ -4,15 +4,23 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockMultipartFile;
+import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.http.AbortableInputStream;
import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
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.*;
@@ -82,4 +90,111 @@ class FileServiceTest {
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
}
+
+ @Test
+ void uploadFile_throwsIOException_whenS3Throws() {
+ MockMultipartFile file = new MockMultipartFile("f", "fail.pdf", "application/pdf", new byte[]{1});
+ S3Exception s3ex = (S3Exception) S3Exception.builder().message("bucket error").statusCode(500).build();
+ when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))).thenThrow(s3ex);
+
+ assertThatThrownBy(() -> fileService.uploadFile(file, "fail.pdf"))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Failed to upload");
+ }
+
+ // ─── downloadFile ─────────────────────────────────────────────────────────
+
+ @Test
+ void downloadFile_returnsResourceWithContentType() {
+ byte[] content = "pdf content".getBytes();
+ GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build();
+ ResponseInputStream stream = new ResponseInputStream<>(
+ response, AbortableInputStream.create(new ByteArrayInputStream(content)));
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
+
+ FileService.S3FileDownload result = fileService.downloadFile("documents/test.pdf");
+
+ assertThat(result.contentType()).isEqualTo("application/pdf");
+ assertThat(result.resource()).isNotNull();
+ }
+
+ @Test
+ void downloadFile_fallsBackToOctetStream_whenContentTypeIsBlank() {
+ byte[] content = "data".getBytes();
+ GetObjectResponse response = GetObjectResponse.builder().contentType(" ").build();
+ ResponseInputStream stream = new ResponseInputStream<>(
+ response, AbortableInputStream.create(new ByteArrayInputStream(content)));
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
+
+ FileService.S3FileDownload result = fileService.downloadFile("documents/file");
+
+ assertThat(result.contentType()).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void downloadFile_fallsBackToOctetStream_whenContentTypeIsNull() {
+ byte[] content = "data".getBytes();
+ GetObjectResponse response = GetObjectResponse.builder().build(); // no contentType
+ ResponseInputStream stream = new ResponseInputStream<>(
+ response, AbortableInputStream.create(new ByteArrayInputStream(content)));
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
+
+ FileService.S3FileDownload result = fileService.downloadFile("documents/file");
+
+ assertThat(result.contentType()).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void downloadFile_throwsStorageFileNotFoundException_whenNoSuchKey() {
+ NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
+
+ assertThatThrownBy(() -> fileService.downloadFile("missing/key.pdf"))
+ .isInstanceOf(FileService.StorageFileNotFoundException.class)
+ .hasMessageContaining("missing/key.pdf");
+ }
+
+ @Test
+ void downloadFile_throwsRuntimeException_whenS3Exception() {
+ S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
+
+ assertThatThrownBy(() -> fileService.downloadFile("documents/file.pdf"))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Storage Error");
+ }
+
+ // ─── downloadFileBytes ────────────────────────────────────────────────────
+
+ @Test
+ void downloadFileBytes_returnsRawBytes() throws IOException {
+ byte[] content = "raw bytes".getBytes();
+ GetObjectResponse response = GetObjectResponse.builder().build();
+ ResponseInputStream stream = new ResponseInputStream<>(
+ response, AbortableInputStream.create(new ByteArrayInputStream(content)));
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
+
+ byte[] result = fileService.downloadFileBytes("documents/file.pdf");
+
+ assertThat(result).isEqualTo(content);
+ }
+
+ @Test
+ void downloadFileBytes_throwsStorageFileNotFoundException_whenNoSuchKey() {
+ NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
+
+ assertThatThrownBy(() -> fileService.downloadFileBytes("missing/key.pdf"))
+ .isInstanceOf(FileService.StorageFileNotFoundException.class);
+ }
+
+ @Test
+ void downloadFileBytes_throwsIOException_whenS3Exception() {
+ S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
+ when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
+
+ assertThatThrownBy(() -> fileService.downloadFileBytes("documents/file.pdf"))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Failed to download");
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/MassImportServiceTest.java
new file mode 100644
index 00000000..f20aec35
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/MassImportServiceTest.java
@@ -0,0 +1,504 @@
+package org.raddatz.familienarchiv.service;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.raddatz.familienarchiv.exception.DomainException;
+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.raddatz.familienarchiv.repository.DocumentRepository;
+import org.springframework.test.util.ReflectionTestUtils;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+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.*;
+
+@ExtendWith(MockitoExtension.class)
+class MassImportServiceTest {
+
+ @Mock DocumentRepository documentRepository;
+ @Mock PersonService personService;
+ @Mock TagService tagService;
+ @Mock S3Client s3Client;
+
+ MassImportService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new MassImportService(documentRepository, personService, tagService, s3Client);
+ ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
+ ReflectionTestUtils.setField(service, "colIndex", 0);
+ ReflectionTestUtils.setField(service, "colBox", 1);
+ ReflectionTestUtils.setField(service, "colFolder", 2);
+ ReflectionTestUtils.setField(service, "colSender", 3);
+ ReflectionTestUtils.setField(service, "colReceivers", 5);
+ ReflectionTestUtils.setField(service, "colDate", 7);
+ ReflectionTestUtils.setField(service, "colLocation", 9);
+ ReflectionTestUtils.setField(service, "colTags", 10);
+ ReflectionTestUtils.setField(service, "colSummary", 11);
+ ReflectionTestUtils.setField(service, "colTranscription", 13);
+ }
+
+ // ─── getStatus ────────────────────────────────────────────────────────────
+
+ @Test
+ void getStatus_returnsIdleByDefault() {
+ assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
+ }
+
+ // ─── runImportAsync ───────────────────────────────────────────────────────
+
+ @Test
+ void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
+ // /import directory doesn't exist in test environment → findSpreadsheetFile throws
+ service.runImportAsync();
+
+ assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
+ }
+
+ @Test
+ void runImportAsync_throwsConflict_whenAlreadyRunning() {
+ MassImportService.ImportStatus running = new MassImportService.ImportStatus(
+ MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
+ ReflectionTestUtils.setField(service, "currentStatus", running);
+
+ assertThatThrownBy(() -> service.runImportAsync())
+ .isInstanceOf(DomainException.class)
+ .hasMessageContaining("already in progress");
+ }
+
+ // ─── importSingleDocument — skip already uploaded ─────────────────────────
+
+ @Test
+ void importSingleDocument_skips_whenDocumentAlreadyUploadedNotPlaceholder() {
+ Document existing = Document.builder()
+ .id(UUID.randomUUID())
+ .originalFilename("doc001.pdf")
+ .status(DocumentStatus.UPLOADED)
+ .build();
+ when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
+
+ service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
+
+ verify(documentRepository, never()).save(any());
+ }
+
+ // ─── importSingleDocument — create new document (metadata only) ───────────
+
+ @Test
+ void importSingleDocument_createsNewDocument_whenNotExists() {
+ when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.importSingleDocument(minimalCells("doc002.pdf"), Optional.empty(), "doc002.pdf", "doc002");
+
+ verify(documentRepository).save(argThat(d ->
+ d.getOriginalFilename().equals("doc002.pdf")
+ && d.getStatus() == DocumentStatus.PLACEHOLDER));
+ }
+
+ // ─── importSingleDocument — update existing placeholder ──────────────────
+
+ @Test
+ void importSingleDocument_updatesExistingPlaceholder() {
+ Document placeholder = Document.builder()
+ .id(UUID.randomUUID())
+ .originalFilename("existing.pdf")
+ .status(DocumentStatus.PLACEHOLDER)
+ .build();
+ when(documentRepository.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.importSingleDocument(minimalCells("existing.pdf"), Optional.empty(), "existing.pdf", "existing");
+
+ verify(documentRepository).save(same(placeholder));
+ }
+
+ // ─── importSingleDocument — with file (S3 upload) ─────────────────────────
+
+ @Test
+ void importSingleDocument_uploadsFileToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception {
+ Path tempFile = tempDir.resolve("doc003.pdf");
+ Files.write(tempFile, "PDF content".getBytes());
+
+ when(documentRepository.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.importSingleDocument(
+ minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003");
+
+ verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+ verify(documentRepository).save(argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
+ }
+
+ @Test
+ void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
+ Path tempFile = tempDir.resolve("fail.pdf");
+ Files.write(tempFile, "data".getBytes());
+
+ when(documentRepository.findByOriginalFilename("fail.pdf")).thenReturn(Optional.empty());
+ doThrow(new RuntimeException("S3 error"))
+ .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+
+ service.importSingleDocument(
+ minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
+
+ verify(documentRepository, never()).save(any());
+ }
+
+ // ─── importSingleDocument — sender handling ───────────────────────────────
+
+ @Test
+ void importSingleDocument_setsNullSender_whenSenderCellIsBlank() {
+ when(documentRepository.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ List cells = buildCells("nosender.pdf", "", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "nosender.pdf", "nosender");
+
+ verify(documentRepository).save(argThat(d -> d.getSender() == null));
+ verify(personService, never()).findOrCreateByAlias(any());
+ }
+
+ @Test
+ void importSingleDocument_createsSender_whenSenderCellIsNonBlank() {
+ Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
+ when(documentRepository.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender);
+
+ List cells = buildCells("withsender.pdf", "Walter Müller", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender");
+
+ verify(personService).findOrCreateByAlias("Walter Müller");
+ verify(documentRepository).save(argThat(d -> d.getSender() == sender));
+ }
+
+ // ─── importSingleDocument — tag handling ─────────────────────────────────
+
+ @Test
+ void importSingleDocument_createsTag_whenTagCellIsNonBlank() {
+ Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
+ when(documentRepository.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(tagService.findOrCreate("Familie")).thenReturn(tag);
+
+ List cells = buildCells("tagged.pdf", "", "", "Familie");
+ service.importSingleDocument(cells, Optional.empty(), "tagged.pdf", "tagged");
+
+ verify(tagService).findOrCreate("Familie");
+ }
+
+ @Test
+ void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() {
+ when(documentRepository.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ List cells = buildCells("notag.pdf", "", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag");
+
+ verify(tagService, never()).findOrCreate(any());
+ }
+
+ // ─── importSingleDocument — metadataComplete heuristic ───────────────────
+
+ @Test
+ void importSingleDocument_metadataComplete_whenSenderPresent() {
+ Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
+ when(documentRepository.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(personService.findOrCreateByAlias("A B")).thenReturn(sender);
+
+ List cells = buildCells("meta.pdf", "A B", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta");
+
+ verify(documentRepository).save(argThat(Document::isMetadataComplete));
+ }
+
+ @Test
+ void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() {
+ when(documentRepository.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ List cells = buildCells("nometa.pdf", "", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "nometa.pdf", "nometa");
+
+ verify(documentRepository).save(argThat(d -> !d.isMetadataComplete()));
+ }
+
+ // ─── importSingleDocument — blank fields set to null ─────────────────────
+
+ @Test
+ void importSingleDocument_setsBlankFieldsToNull() {
+ when(documentRepository.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ List cells = buildCells("blank.pdf", "", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank");
+
+ verify(documentRepository).save(argThat(d ->
+ d.getLocation() == null &&
+ d.getSummary() == null &&
+ d.getTranscription() == null &&
+ d.getArchiveBox() == null &&
+ d.getArchiveFolder() == null));
+ }
+
+ // ─── processRows — via ReflectionTestUtils ────────────────────────────────
+
+ @Test
+ void processRows_returnsZero_whenOnlyHeaderRow() {
+ List> rows = List.of(List.of("header", "col1"));
+ Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+ assertThat(result).isEqualTo(0);
+ }
+
+ @Test
+ void processRows_skipsRowWithBlankIndex() {
+ List> rows = List.of(
+ List.of("header"),
+ minimalCells("") // blank index
+ );
+ Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+ assertThat(result).isEqualTo(0);
+ verify(documentRepository, never()).findByOriginalFilename(any());
+ }
+
+ @Test
+ void processRows_addsExtension_whenIndexHasNoDot() {
+ when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ List> rows = List.of(
+ List.of("header"),
+ minimalCells("doc001") // no dot → appends ".pdf"
+ );
+ Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+
+ assertThat(result).isEqualTo(1);
+ verify(documentRepository).findByOriginalFilename("doc001.pdf");
+ }
+
+ @Test
+ void processRows_usesFilenameAsIs_whenIndexHasDot() {
+ when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ List> rows = List.of(
+ List.of("header"),
+ minimalCells("doc002.pdf") // has dot → used as-is
+ );
+ Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+
+ assertThat(result).isEqualTo(1);
+ verify(documentRepository).findByOriginalFilename("doc002.pdf");
+ }
+
+ // ─── importSingleDocument — non-blank optional fields ────────────────────
+
+ @Test
+ void importSingleDocument_setsNonNullOptionalFields_whenPresent() {
+ when(documentRepository.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ // box=1, folder=2, location=9, summary=11, transcription=13
+ List cells = List.of(
+ "rich.pdf", // 0: index
+ "Box A", // 1: box
+ "Folder B", // 2: folder
+ "", // 3: sender
+ "", // 4: unused
+ "", // 5: receivers
+ "", // 6: unused
+ "", // 7: date
+ "", // 8: unused
+ "Hamburg", // 9: location
+ "", // 10: tags
+ "A summary", // 11: summary
+ "", // 12: unused
+ "A transcript" // 13: transcription
+ );
+
+ service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich");
+
+ verify(documentRepository).save(argThat(d ->
+ "Box A".equals(d.getArchiveBox()) &&
+ "Folder B".equals(d.getArchiveFolder()) &&
+ "Hamburg".equals(d.getLocation()) &&
+ "A summary".equals(d.getSummary()) &&
+ "A transcript".equals(d.getTranscription())));
+ }
+
+ @Test
+ void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() {
+ Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
+ when(documentRepository.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver);
+
+ List cells = List.of(
+ "rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv");
+
+ verify(documentRepository).save(argThat(Document::isMetadataComplete));
+ }
+
+ @Test
+ void importSingleDocument_setsMetadataComplete_whenDateIsPresent() {
+ when(documentRepository.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ List cells = List.of(
+ "dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", "");
+ service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated");
+
+ verify(documentRepository).save(argThat(Document::isMetadataComplete));
+ }
+
+ // ─── buildTitle — null location ───────────────────────────────────────────
+
+ @Test
+ void buildTitle_withNullLocation_skipsLocationPart() {
+ String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
+ "doc005", LocalDate.of(1940, 5, 1), (String) null);
+ assertThat(result).contains("doc005").contains("1940");
+ assertThat(result).doesNotContain("Berlin");
+ }
+
+ // ─── parseDate — via ReflectionTestUtils ─────────────────────────────────
+
+ @Test
+ void parseDate_returnsNull_whenValueIsNull() {
+ LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", (String) null);
+ assertThat(result).isNull();
+ }
+
+ @Test
+ void parseDate_returnsNull_whenValueIsBlank() {
+ LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", " ");
+ assertThat(result).isNull();
+ }
+
+ @Test
+ void parseDate_returnsDate_whenValidIsoFormat() {
+ LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "2024-03-15");
+ assertThat(result).isEqualTo(LocalDate.of(2024, 3, 15));
+ }
+
+ @Test
+ void parseDate_returnsNull_whenInvalidDateString() {
+ LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "15.03.2024");
+ assertThat(result).isNull();
+ }
+
+ // ─── buildTitle — via ReflectionTestUtils ────────────────────────────────
+
+ @Test
+ void buildTitle_withDateAndLocation() {
+ String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
+ "doc001", LocalDate.of(1940, 5, 1), "Berlin");
+ assertThat(result).contains("doc001").contains("Berlin").contains("1940");
+ }
+
+ @Test
+ void buildTitle_withDateOnly() {
+ String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
+ "doc002", LocalDate.of(1960, 8, 15), "");
+ assertThat(result).contains("doc002").contains("1960");
+ assertThat(result).doesNotContain("Berlin");
+ }
+
+ @Test
+ void buildTitle_withIndexOnly_whenDateAndLocationAreNull() {
+ String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
+ "doc003", null, "");
+ assertThat(result).isEqualTo("doc003");
+ }
+
+ @Test
+ void buildTitle_withLocationOnly_whenDateIsNull() {
+ // date=null, location present → date part skipped, location appended
+ String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
+ "doc004", null, "Berlin");
+ assertThat(result).contains("doc004").contains("Berlin");
+ assertThat(result).doesNotContain("("); // no date part
+ }
+
+ // ─── getCell — via ReflectionTestUtils ───────────────────────────────────
+
+ @Test
+ void getCell_returnsEmptyString_whenColBeyondListSize() {
+ List cells = List.of("a", "b");
+ String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 5);
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void getCell_returnsEmptyString_whenValueIsNull() {
+ List cells = new ArrayList<>();
+ cells.add(null);
+ cells.add("b");
+ String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void getCell_returnsTrimmedValue() {
+ List cells = List.of(" hello ", "world");
+ String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
+ assertThat(result).isEqualTo("hello");
+ }
+
+ // ─── helpers ──────────────────────────────────────────────────────────────
+
+ /**
+ * Builds a minimal 14-element cell row with the given filename at index 0
+ * and blanks for all optional fields.
+ */
+ private List minimalCells(String filename) {
+ return buildCells(filename, "", "", "");
+ }
+
+ /**
+ * Builds a cell row with sender, receiver, and tag controls.
+ * Layout matches the default column indices set in setUp().
+ */
+ private List buildCells(String filename, String sender, String receivers, String tag) {
+ // 14 elements: index=0,box=1,folder=2,sender=3,[4],receivers=5,[6],date=7,[8],location=9,tag=10,summary=11,[12],transcription=13
+ return List.of(
+ filename, // 0: index
+ "", // 1: box
+ "", // 2: folder
+ sender, // 3: sender
+ "", // 4: (unused)
+ receivers, // 5: receivers
+ "", // 6: (unused)
+ "", // 7: date
+ "", // 8: (unused)
+ "", // 9: location
+ tag, // 10: tags
+ "", // 11: summary
+ "", // 12: (unused)
+ "" // 13: transcription
+ );
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java
index 27d25f94..1977584b 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java
@@ -10,6 +10,8 @@ import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.*;
import org.raddatz.familienarchiv.repository.NotificationRepository;
+import org.springframework.mail.MailException;
+import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
@@ -207,6 +209,27 @@ class NotificationServiceTest {
verify(notificationRepository).markAllReadByRecipientId(userA.getId());
}
+ // ─── markRead — happy path ────────────────────────────────────────────────
+
+ @Test
+ void markRead_marksNotificationAsRead_whenRecipientMatches() {
+ Notification notification = Notification.builder()
+ .id(UUID.randomUUID())
+ .recipient(userA)
+ .type(NotificationType.REPLY)
+ .documentId(UUID.randomUUID())
+ .referenceId(UUID.randomUUID())
+ .read(false)
+ .build();
+ when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
+ when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ NotificationDTO result = notificationService.markRead(notification.getId(), userA.getId());
+
+ assertThat(result).isNotNull();
+ assertThat(notification.isRead()).isTrue();
+ }
+
// ─── countUnread ──────────────────────────────────────────────────────────
@Test
@@ -216,6 +239,123 @@ class NotificationServiceTest {
assertThat(notificationService.countUnread(userA.getId())).isEqualTo(3L);
}
+ // ─── notifyMentions — null list ───────────────────────────────────────────
+
+ @Test
+ void notifyMentions_doesNothing_whenMentionedUserIdsIsNull() {
+ DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
+
+ notificationService.notifyMentions(null, comment);
+
+ verify(notificationRepository, never()).save(any());
+ }
+
+ // ─── email — no mailSender ────────────────────────────────────────────────
+
+ @Test
+ void notifyReply_skipsEmail_whenMailSenderIsAbsent() {
+ NotificationService serviceWithoutMail = new NotificationService(
+ notificationRepository, userService, Optional.empty(), sseEmitterRegistry);
+
+ userA.setNotifyOnReply(true);
+ DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
+
+ when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
+ when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ serviceWithoutMail.notifyReply(reply, Set.of(userA.getId()));
+
+ verify(mailSender, never()).send(any(SimpleMailMessage.class));
+ }
+
+ @Test
+ void notifyMentions_skipsEmail_whenMailSenderIsAbsent() {
+ NotificationService serviceWithoutMail = new NotificationService(
+ notificationRepository, userService, Optional.empty(), sseEmitterRegistry);
+
+ userA.setNotifyOnMention(true);
+ DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
+
+ when(userService.findAllById(List.of(userA.getId()))).thenReturn(List.of(userA));
+ when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ serviceWithoutMail.notifyMentions(List.of(userA.getId()), comment);
+
+ verify(mailSender, never()).send(any(SimpleMailMessage.class));
+ }
+
+ // ─── email — recipient email missing ─────────────────────────────────────
+
+ @Test
+ void notifyReply_skipsEmail_whenRecipientEmailIsNull() {
+ userA.setNotifyOnReply(true);
+ userA.setEmail(null);
+ DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
+
+ when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
+ when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ notificationService.notifyReply(reply, Set.of(userA.getId()));
+
+ verify(mailSender, never()).send(any(SimpleMailMessage.class));
+ }
+
+ @Test
+ void notifyReply_skipsEmail_whenRecipientEmailIsBlank() {
+ userA.setNotifyOnReply(true);
+ userA.setEmail(" ");
+ DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
+
+ when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
+ when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ notificationService.notifyReply(reply, Set.of(userA.getId()));
+
+ verify(mailSender, never()).send(any(SimpleMailMessage.class));
+ }
+
+ // ─── email — MailException swallowed ─────────────────────────────────────
+
+ @Test
+ void notifyReply_doesNotThrow_whenMailExceptionOccurs() {
+ userA.setNotifyOnReply(true);
+ DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
+
+ when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
+ when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ doThrow(new MailSendException("SMTP down")).when(mailSender).send(any(SimpleMailMessage.class));
+
+ // Must not throw — MailException is caught and logged
+ notificationService.notifyReply(reply, Set.of(userA.getId()));
+
+ verify(mailSender).send(any(SimpleMailMessage.class));
+ }
+
+ // ─── email — annotationId included in link ────────────────────────────────
+
+ @Test
+ void notifyReply_includesAnnotationIdInEmailLink_whenAnnotationPresent() {
+ userA.setNotifyOnReply(true);
+ UUID annotationId = UUID.randomUUID();
+ DocumentComment reply = DocumentComment.builder()
+ .id(UUID.randomUUID())
+ .documentId(UUID.randomUUID())
+ .annotationId(annotationId)
+ .authorId(userC.getId())
+ .authorName("Clara Doe")
+ .content("reply")
+ .build();
+
+ when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
+ when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ notificationService.notifyReply(reply, Set.of(userA.getId()));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
+ verify(mailSender).send(captor.capture());
+ assertThat(captor.getValue().getText()).contains("annotationId=" + annotationId);
+ }
+
// ─── private helpers ──────────────────────────────────────────────────────
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java
index 762efb9c..6f10df96 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java
@@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doThrow;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -23,8 +24,11 @@ import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
+import org.springframework.mail.MailSendException;
+import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest {
@@ -123,4 +127,62 @@ class PasswordResetServiceTest {
assertThatThrownBy(() -> service.resetPassword(req))
.isInstanceOf(DomainException.class);
}
+
+ @Test
+ void resetPassword_throwsForAlreadyUsedToken() {
+ AppUser user = makeUser("user@example.com");
+ PasswordResetToken token = PasswordResetToken.builder()
+ .token("usedtoken")
+ .user(user)
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .used(true) // already used
+ .build();
+ when(tokenRepository.findByToken("usedtoken")).thenReturn(Optional.of(token));
+
+ ResetPasswordRequest req = new ResetPasswordRequest();
+ req.setToken("usedtoken");
+ req.setNewPassword("newpass");
+
+ assertThatThrownBy(() -> service.resetPassword(req))
+ .isInstanceOf(DomainException.class);
+ }
+
+ // ─── requestReset — mail sending branches ─────────────────────────────────
+
+ @Test
+ void requestReset_skipsEmail_whenMailSenderIsNull() {
+ ReflectionTestUtils.setField(service, "mailSender", null);
+ AppUser user = makeUser("user@example.com");
+ when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
+
+ // Must not throw even without mail sender
+ service.requestReset("user@example.com", "http://localhost:3000");
+
+ verify(tokenRepository).save(any());
+ verify(mailSender, never()).send(any(SimpleMailMessage.class));
+ }
+
+ @Test
+ void requestReset_logsError_whenMailExceptionThrown() {
+ // mailSender is @Autowired(required=false) — not in constructor, so needs explicit injection
+ ReflectionTestUtils.setField(service, "mailSender", mailSender);
+ AppUser user = makeUser("user@example.com");
+ when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
+ doThrow(new MailSendException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
+
+ // Must not propagate the MailException
+ service.requestReset("user@example.com", "http://localhost:3000");
+
+ verify(tokenRepository).save(any());
+ verify(mailSender).send(any(SimpleMailMessage.class));
+ }
+
+ // ─── cleanupExpiredTokens ─────────────────────────────────────────────────
+
+ @Test
+ void cleanupExpiredTokens_delegatesToRepository() {
+ service.cleanupExpiredTokens();
+
+ verify(tokenRepository).deleteExpiredAndUsed(any(LocalDateTime.class));
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index 1b22c4b9..01ab46d2 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -117,4 +117,50 @@ class PersonNameParserTest {
assertThat(result.firstName()).isEqualTo("?");
assertThat(result.lastName()).isEqualTo("?");
}
+
+ @Test
+ void split_blank_returnsPlaceholder() {
+ PersonNameParser.SplitName result = PersonNameParser.split(" ");
+ assertThat(result.firstName()).isEqualTo("?");
+ assertThat(result.lastName()).isEqualTo("?");
+ }
+
+ @Test
+ void split_onlyKnownLastName_firstNameFallsBackToCleaned() {
+ // "de Gruyter" alone → firstName would be blank after removing last name, so cleaned is used
+ PersonNameParser.SplitName result = PersonNameParser.split("de Gruyter");
+ assertThat(result.firstName()).isEqualTo("de Gruyter");
+ assertThat(result.lastName()).isEqualTo("de Gruyter");
+ }
+
+ // --- parseReceivers — shared last name with full-name part ─────────────────
+
+ @Test
+ void parseReceivers_partWithSpace_notAppended_whenParenLastNamePresent() {
+ // "Clara Cram und Hans (Müller)": Clara Cram already has a space → keep as-is
+ List result = PersonNameParser.parseReceivers("Clara Cram und Hans (Müller)");
+ assertThat(result).containsExactlyInAnyOrder("Clara Cram", "Hans Müller");
+ }
+
+ @Test
+ void parseReceivers_partAlreadyFullName_notDistributed_fromLastSegmentLastName() {
+ // "Clara Cram und Eugenie de Gruyter": first part has its own name, no distribution
+ List result = PersonNameParser.parseReceivers("Clara Cram und Eugenie de Gruyter");
+ assertThat(result).containsExactlyInAnyOrder("Clara Cram", "Eugenie de Gruyter");
+ }
+
+ @Test
+ void parseReceivers_returnsEmpty_whenAllPartsAreFamilie() {
+ // All parts filtered out → nameParts.isEmpty() = true → return List.of()
+ assertThat(PersonNameParser.parseReceivers("Familie und Familie")).isEmpty();
+ }
+
+ @Test
+ void parseReceivers_singleTokenKnownLastName_notDistributed() {
+ // "Müller und Herbert de Gruyter":
+ // last segment = "Herbert de Gruyter" → detectedLastName = "de Gruyter"
+ // "Müller": !contains(" ") = true BUT findKnownLastName("Müller") != null → else branch → kept as-is
+ List result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
+ assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter");
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
index 0d02fc1e..f2eb3628 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
@@ -47,6 +47,98 @@ class PersonServiceTest {
assertThat(personService.getById(id)).isEqualTo(person);
}
+ // ─── findAll ─────────────────────────────────────────────────────────────
+
+ @Test
+ void findAll_returnsAll_whenQueryIsNull() {
+ List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build());
+ when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
+
+ assertThat(personService.findAll(null)).isEqualTo(expected);
+ verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
+ verify(personRepository, never()).searchByName(any());
+ }
+
+ @Test
+ void findAll_returnsAll_whenQueryIsBlank() {
+ List expected = List.of();
+ when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
+
+ assertThat(personService.findAll(" ")).isEqualTo(expected);
+ verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
+ verify(personRepository, never()).searchByName(any());
+ }
+
+ @Test
+ void findAll_searchesByName_whenQueryIsNonBlank() {
+ List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Müller").build());
+ when(personRepository.searchByName("Anna")).thenReturn(expected);
+
+ assertThat(personService.findAll("Anna")).isEqualTo(expected);
+ verify(personRepository).searchByName("Anna");
+ verify(personRepository, never()).findAllByOrderByLastNameAscFirstNameAsc();
+ }
+
+ // ─── createPerson ─────────────────────────────────────────────────────────
+
+ @Test
+ void createPerson_savesPersonWithNullAlias_whenAliasIsNull() {
+ when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Person result = personService.createPerson("Hans", "Müller", null);
+
+ assertThat(result.getAlias()).isNull();
+ verify(personRepository).save(argThat(p -> p.getFirstName().equals("Hans") && p.getAlias() == null));
+ }
+
+ @Test
+ void createPerson_savesPersonWithNullAlias_whenAliasIsBlank() {
+ when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Person result = personService.createPerson("Hans", "Müller", " ");
+
+ assertThat(result.getAlias()).isNull();
+ }
+
+ @Test
+ void createPerson_savesTrimmedAlias_whenAliasIsNonBlank() {
+ when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Person result = personService.createPerson("Hans", "Müller", " Hans Müller ");
+
+ assertThat(result.getAlias()).isEqualTo("Hans Müller");
+ }
+
+ // ─── updatePerson (alias) ─────────────────────────────────────────────────
+
+ @Test
+ void updatePerson_setsNullAlias_whenAliasIsBlank() {
+ UUID id = UUID.randomUUID();
+ Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").alias("old alias").build();
+ when(personRepository.findById(id)).thenReturn(Optional.of(person));
+ when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ PersonUpdateDTO dto = new PersonUpdateDTO();
+ dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setAlias(" ");
+ Person result = personService.updatePerson(id, dto);
+
+ assertThat(result.getAlias()).isNull();
+ }
+
+ @Test
+ void updatePerson_setsTrimmedAlias_whenAliasIsNonBlank() {
+ UUID id = UUID.randomUUID();
+ Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
+ when(personRepository.findById(id)).thenReturn(Optional.of(person));
+ when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ PersonUpdateDTO dto = new PersonUpdateDTO();
+ dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setAlias(" Anna Alt ");
+ Person result = personService.updatePerson(id, dto);
+
+ assertThat(result.getAlias()).isEqualTo("Anna Alt");
+ }
+
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test
@@ -144,6 +236,22 @@ class PersonServiceTest {
.isEqualTo(400);
}
+ @Test
+ void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
+ // Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
+ UUID id = UUID.randomUUID();
+ Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
+ when(personRepository.findById(id)).thenReturn(Optional.of(person));
+ when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ PersonUpdateDTO dto = new PersonUpdateDTO();
+ dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null);
+ Person result = personService.updatePerson(id, dto);
+
+ assertThat(result.getBirthYear()).isEqualTo(1890);
+ assertThat(result.getDeathYear()).isNull();
+ }
+
@Test
void updatePerson_allowsSameYear() {
UUID id = UUID.randomUUID();
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java
index d5950ac5..e2a4e967 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java
@@ -34,4 +34,14 @@ class SseEmitterRegistryTest {
assertThat(first).isNotSameAs(second);
}
+
+ @Test
+ void send_doesNotThrow_whenEmitterRegistered_andSendFails() {
+ // Registering an emitter without an active HTTP connection causes IOException on send
+ UUID userId = UUID.randomUUID();
+ registry.register(userId);
+
+ // Must not propagate the IOException — it's caught and the emitter is removed
+ assertThatCode(() -> registry.send(userId, "data")).doesNotThrowAnyException();
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserSearchServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserSearchServiceTest.java
new file mode 100644
index 00000000..9b0dd5bf
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserSearchServiceTest.java
@@ -0,0 +1,67 @@
+package org.raddatz.familienarchiv.service;
+
+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.model.AppUser;
+import org.raddatz.familienarchiv.repository.AppUserRepository;
+import org.springframework.data.domain.PageRequest;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class UserSearchServiceTest {
+
+ @Mock AppUserRepository userRepository;
+ @InjectMocks UserSearchService userSearchService;
+
+ // ─── search ───────────────────────────────────────────────────────────────
+
+ @Test
+ void search_returnsEmpty_whenQueryIsNull() {
+ List result = userSearchService.search(null);
+
+ assertThat(result).isEmpty();
+ verify(userRepository, never()).searchByNameOrUsername(any(), any());
+ }
+
+ @Test
+ void search_returnsEmpty_whenQueryIsBlank() {
+ List result = userSearchService.search(" ");
+
+ assertThat(result).isEmpty();
+ verify(userRepository, never()).searchByNameOrUsername(any(), any());
+ }
+
+ @Test
+ void search_delegatesToRepository_whenQueryIsNonBlank() {
+ AppUser user = AppUser.builder().id(UUID.randomUUID()).username("hans").build();
+ when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
+ .thenReturn(List.of(user));
+
+ List result = userSearchService.search("hans");
+
+ assertThat(result).containsExactly(user);
+ verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
+ }
+
+ @Test
+ void search_trimsQuery_beforeDelegating() {
+ when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
+ .thenReturn(List.of());
+
+ userSearchService.search(" hans ");
+
+ verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java
index 1e032979..954614ea 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java
@@ -301,4 +301,378 @@ class UserServiceTest {
assertThatThrownBy(() -> userService.getGroupById(id))
.isInstanceOf(DomainException.class);
}
+
+ // ─── createUserOrUpdate — groups loaded ───────────────────────────────────
+
+ @Test
+ void createUserOrUpdate_loadsGroups_whenGroupIdsNonEmpty() {
+ UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
+ CreateUserRequest req = new CreateUserRequest();
+ req.setUsername("newuser");
+ req.setEmail("u@example.com");
+ req.setInitialPassword("pass");
+ req.setGroupIds(List.of(group.getId()));
+
+ when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
+ when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
+ when(passwordEncoder.encode("pass")).thenReturn("encoded");
+ AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
+ when(userRepository.save(any())).thenReturn(saved);
+
+ AppUser result = userService.createUserOrUpdate(req);
+
+ assertThat(result).isEqualTo(saved);
+ verify(groupRepository).findAllById(List.of(group.getId()));
+ }
+
+ // ─── updateProfile — email edge cases ─────────────────────────────────────
+
+ @Test
+ void updateProfile_setsEmailToNull_whenEmailIsBlank() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("max").email("old@example.com").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ UpdateProfileDTO dto = new UpdateProfileDTO();
+ dto.setEmail(" "); // blank — should clear email
+
+ AppUser result = userService.updateProfile(id, dto);
+
+ assertThat(result.getEmail()).isNull();
+ }
+
+ @Test
+ void updateProfile_doesNotChangeEmail_whenEmailDtoIsNull() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("max").email("keep@example.com").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ UpdateProfileDTO dto = new UpdateProfileDTO();
+ dto.setEmail(null); // null — no change
+
+ AppUser result = userService.updateProfile(id, dto);
+
+ assertThat(result.getEmail()).isEqualTo("keep@example.com");
+ }
+
+ @Test
+ void updateProfile_setsContactToNull_whenContactIsBlank() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("max").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ UpdateProfileDTO dto = new UpdateProfileDTO();
+ dto.setContact(" ");
+
+ AppUser result = userService.updateProfile(id, dto);
+
+ assertThat(result.getContact()).isNull();
+ }
+
+ // ─── adminUpdateUser — password and email branches ────────────────────────
+
+ @Test
+ void adminUpdateUser_setsPassword_whenNewPasswordProvided() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").password("old").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(passwordEncoder.encode("newSecret")).thenReturn("newHashed");
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setNewPassword("newSecret");
+
+ AppUser result = userService.adminUpdateUser(id, dto);
+
+ assertThat(result.getPassword()).isEqualTo("newHashed");
+ }
+
+ @Test
+ void adminUpdateUser_doesNotChangePassword_whenNewPasswordIsBlank() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").password("original").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setNewPassword(" ");
+
+ AppUser result = userService.adminUpdateUser(id, dto);
+
+ assertThat(result.getPassword()).isEqualTo("original");
+ verify(passwordEncoder, never()).encode(any());
+ }
+
+ @Test
+ void adminUpdateUser_setsEmailToNull_whenEmailIsBlank() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").email("old@example.com").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setEmail(" ");
+
+ AppUser result = userService.adminUpdateUser(id, dto);
+
+ assertThat(result.getEmail()).isNull();
+ }
+
+ @Test
+ void adminUpdateUser_throwsConflict_whenEmailTakenByAnotherUser() {
+ UUID id = UUID.randomUUID();
+ UUID otherId = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").build();
+ AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setEmail("taken@example.com");
+
+ assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
+ .isInstanceOf(DomainException.class)
+ .hasMessageContaining("E-Mail");
+ }
+
+ // ─── updateGroup ──────────────────────────────────────────────────────────
+
+ @Test
+ void updateGroup_updatesNameAndPermissions_whenBothProvided() {
+ UUID id = UUID.randomUUID();
+ UserGroup group = UserGroup.builder().id(id).name("OldName")
+ .permissions(Set.of("READ_ALL")).build();
+ when(groupRepository.findById(id)).thenReturn(Optional.of(group));
+ when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
+ dto.setName("NewName");
+ dto.setPermissions(Set.of("WRITE_ALL"));
+
+ UserGroup result = userService.updateGroup(id, dto);
+
+ assertThat(result.getName()).isEqualTo("NewName");
+ assertThat(result.getPermissions()).containsExactly("WRITE_ALL");
+ }
+
+ @Test
+ void updateGroup_keepsExistingName_whenNameIsNull() {
+ UUID id = UUID.randomUUID();
+ UserGroup group = UserGroup.builder().id(id).name("Existing").build();
+ when(groupRepository.findById(id)).thenReturn(Optional.of(group));
+ when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
+ dto.setName(null);
+ dto.setPermissions(Set.of("ADMIN"));
+
+ UserGroup result = userService.updateGroup(id, dto);
+
+ assertThat(result.getName()).isEqualTo("Existing");
+ }
+
+ @Test
+ void updateGroup_keepsExistingPermissions_whenPermissionsAreNull() {
+ UUID id = UUID.randomUUID();
+ UserGroup group = UserGroup.builder().id(id).name("Group")
+ .permissions(Set.of("READ_ALL")).build();
+ when(groupRepository.findById(id)).thenReturn(Optional.of(group));
+ when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
+ dto.setName("NewName");
+ dto.setPermissions(null);
+
+ UserGroup result = userService.updateGroup(id, dto);
+
+ assertThat(result.getPermissions()).containsExactly("READ_ALL");
+ }
+
+ // ─── createUserOrUpdate — empty groupIds ──────────────────────────────────
+
+ @Test
+ void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsEmpty() {
+ CreateUserRequest req = new CreateUserRequest();
+ req.setUsername("newuser");
+ req.setEmail("u@example.com");
+ req.setInitialPassword("pass");
+ req.setGroupIds(List.of()); // empty, not null
+
+ when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
+ when(passwordEncoder.encode("pass")).thenReturn("encoded");
+ AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
+ when(userRepository.save(any())).thenReturn(saved);
+
+ userService.createUserOrUpdate(req);
+
+ verify(groupRepository, never()).findAllById(any());
+ }
+
+ // ─── updateProfile — contact null ─────────────────────────────────────────
+
+ @Test
+ void updateProfile_setsTrimmedContact_whenContactIsNonBlank() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("max").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ UpdateProfileDTO dto = new UpdateProfileDTO();
+ dto.setContact(" phone: 999 ");
+
+ AppUser result = userService.updateProfile(id, dto);
+
+ assertThat(result.getContact()).isEqualTo("phone: 999");
+ }
+
+ @Test
+ void updateProfile_setsNullContact_whenContactIsNull() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("max").contact("old contact").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ UpdateProfileDTO dto = new UpdateProfileDTO();
+ dto.setContact(null);
+
+ AppUser result = userService.updateProfile(id, dto);
+
+ assertThat(result.getContact()).isNull();
+ }
+
+ @Test
+ void updateProfile_allowsSameEmail_whenEmailBelongsToSameUser() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("max").email("me@example.com").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ UpdateProfileDTO dto = new UpdateProfileDTO();
+ dto.setEmail("me@example.com");
+
+ // Must not throw
+ AppUser result = userService.updateProfile(id, dto);
+ assertThat(result.getEmail()).isEqualTo("me@example.com");
+ }
+
+ // ─── adminUpdateUser — contact null and email null ────────────────────────
+
+ @Test
+ void adminUpdateUser_setsNullContact_whenContactIsNull() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").contact("old contact").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setContact(null);
+
+ AppUser result = userService.adminUpdateUser(id, dto);
+
+ assertThat(result.getContact()).isNull();
+ }
+
+ @Test
+ void adminUpdateUser_setsNullContact_whenContactIsBlank() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").contact("old").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setContact(" ");
+
+ AppUser result = userService.adminUpdateUser(id, dto);
+
+ assertThat(result.getContact()).isNull();
+ }
+
+ @Test
+ void adminUpdateUser_setsTrimmedContact_whenContactIsNonBlank() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setContact(" phone: 555 ");
+
+ AppUser result = userService.adminUpdateUser(id, dto);
+
+ assertThat(result.getContact()).isEqualTo("phone: 555");
+ }
+
+ @Test
+ void adminUpdateUser_doesNotModifyEmail_whenEmailIsNull() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").email("keep@example.com").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setEmail(null);
+
+ AppUser result = userService.adminUpdateUser(id, dto);
+
+ assertThat(result.getEmail()).isEqualTo("keep@example.com");
+ }
+
+ @Test
+ void adminUpdateUser_allowsSameEmail_whenEmailBelongsToSameUser() {
+ UUID id = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(id).username("admin").email("me@example.com").build();
+ when(userRepository.findById(id)).thenReturn(Optional.of(user));
+ when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
+ when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
+ dto.setEmail("me@example.com");
+
+ // Must not throw
+ AppUser result = userService.adminUpdateUser(id, dto);
+ assertThat(result.getEmail()).isEqualTo("me@example.com");
+ }
+
+ // ─── createUserOrUpdate — null groupIds ──────────────────────────────────
+
+ @Test
+ void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsNull() {
+ // request.getGroupIds() == null → short-circuit (A=false), groupRepository never called
+ CreateUserRequest req = new CreateUserRequest();
+ req.setUsername("nullgroups");
+ req.setEmail("ng@example.com");
+ req.setInitialPassword("pass");
+ req.setGroupIds(null); // null → first condition false → short-circuit
+
+ when(userRepository.findByUsername("nullgroups")).thenReturn(Optional.empty());
+ when(passwordEncoder.encode("pass")).thenReturn("encoded");
+ AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("nullgroups").build();
+ when(userRepository.save(any())).thenReturn(saved);
+
+ userService.createUserOrUpdate(req);
+
+ verify(groupRepository, never()).findAllById(any());
+ }
+
+ // ─── createGroup ──────────────────────────────────────────────────────────
+
+ @Test
+ void createGroup_createsGroupWithNameAndPermissions() {
+ org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
+ dto.setName("Familie");
+ dto.setPermissions(Set.of("READ_ALL", "WRITE_ALL"));
+
+ UserGroup saved = UserGroup.builder().id(UUID.randomUUID()).name("Familie")
+ .permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
+ when(groupRepository.save(any())).thenReturn(saved);
+
+ UserGroup result = userService.createGroup(dto);
+
+ assertThat(result.getName()).isEqualTo("Familie");
+ assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
+ }
}