test(#148): service unit tests — 90.2% branch coverage #150

Merged
marcel merged 4 commits from test/148-coverage-controller-specifications into main 2026-03-28 23:42:32 +01:00
23 changed files with 3561 additions and 4 deletions

View File

@@ -193,8 +193,7 @@
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
<!-- Gate: current baseline 46.8% — threshold set at 42% to prevent regression.
Target is 80%; close the gap by adding tests for service error paths. -->
<!-- Gate: baseline 89.4% overall / service 90.2% / controller 80.0% -->
<execution>
<id>check</id>
<phase>verify</phase>
@@ -207,7 +206,7 @@
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.42</minimum>
<minimum>0.88</minimum>
</limit>
</limits>
</rule>

View File

@@ -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<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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"));
}
}

View File

@@ -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<Document> result = documentRepository.findAll(Specification.where(hasText(null)));
assertThat(result).hasSize(3);
}
@Test
void hasText_returnsAllDocuments_whenTextIsBlank() {
List<Document> result = documentRepository.findAll(Specification.where(hasText(" ")));
assertThat(result).hasSize(3);
}
@Test
void hasText_filtersOnTitle() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("familienfoto")));
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
}
@Test
void hasText_filtersOnOriginalFilename() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("brief_late")));
assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief");
}
@Test
void hasText_filtersOnTranscription() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("schreibe dir")));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasText_filtersOnLocation() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("berlin")));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasText_isCaseInsensitive() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("BRIEF")));
assertThat(result).extracting(Document::getTitle).containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
}
@Test
void hasText_returnsEmpty_whenNoMatch() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("xyznotexist")));
assertThat(result).isEmpty();
}
// ─── hasSender ────────────────────────────────────────────────────────────
@Test
void hasSender_returnsAllDocuments_whenPersonIdIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasSender(null)));
assertThat(result).hasSize(3);
}
@Test
void hasSender_filtersDocumentsBySender() {
List<Document> result = documentRepository.findAll(Specification.where(hasSender(sender.getId())));
assertThat(result).extracting(Document::getTitle)
.containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
}
@Test
void hasSender_returnsEmpty_whenSenderHasNoDocuments() {
List<Document> result = documentRepository.findAll(Specification.where(hasSender(receiver.getId())));
assertThat(result).isEmpty();
}
// ─── hasReceiver ──────────────────────────────────────────────────────────
@Test
void hasReceiver_returnsAllDocuments_whenPersonIdIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(null)));
assertThat(result).hasSize(3);
}
@Test
void hasReceiver_filtersDocumentsByReceiver() {
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(receiver.getId())));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasReceiver_returnsEmpty_whenReceiverHasNoDocuments() {
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(sender.getId())));
assertThat(result).isEmpty();
}
// ─── isBetween ────────────────────────────────────────────────────────────
@Test
void isBetween_returnsAllDocuments_whenBothDatesAreNull() {
List<Document> result = documentRepository.findAll(Specification.where(isBetween(null, null)));
assertThat(result).hasSize(3);
}
@Test
void isBetween_filtersByBothDates() {
List<Document> 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<Document> 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<Document> 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<Document> 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<Document> result = documentRepository.findAll(Specification.where(hasTags(null)));
assertThat(result).hasSize(3);
}
@Test
void hasTags_returnsAllDocuments_whenTagListIsEmpty() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of())));
assertThat(result).hasSize(3);
}
@Test
void hasTags_filtersDocumentsByTag() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie"))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasTags_isCaseInsensitive() {
List<Document> 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<Document> 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<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie"))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasTags_returnsEmpty_whenTagDoesNotExist() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt"))));
assertThat(result).isEmpty();
}
}

View File

@@ -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<Person> 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<Person> 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<Person> 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<Person> 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<Document> 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();
}
}

View File

@@ -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());
}
}

View File

@@ -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<DocumentComment> result = commentService.getCommentsForAnnotation(annotationId);
assertThat(result).hasSize(1);
assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId);
}
// ─── helpers ──────────────────────────────────────────────────────────────
private AppUser buildAdmin() {

View File

@@ -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");
}
}

View File

@@ -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<LocalDate> 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<LocalDate> 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<Document> 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<Document> 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();
}
}

View File

@@ -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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getSavedAt()).isNotNull();
}
// ─── helpers ──────────────────────────────────────────────────────────────
private void authenticateAs(String username) {

View File

@@ -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<GetObjectResponse> 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<GetObjectResponse> 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<GetObjectResponse> 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<GetObjectResponse> 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");
}
}

View File

@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<List<String>> rows = List.of(List.of("header", "col1"));
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(0);
}
@Test
void processRows_skipsRowWithBlankIndex() {
List<List<String>> 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<List<String>> 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<List<String>> 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<String> 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<String> 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<String> 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<String> cells = List.of("a", "b");
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 5);
assertThat(result).isEmpty();
}
@Test
void getCell_returnsEmptyString_whenValueIsNull() {
List<String> 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<String> 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<String> 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<String> 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
);
}
}

View File

@@ -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<SimpleMailMessage> 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) {

View File

@@ -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));
}
}

View File

@@ -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<String> 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<String> 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<String> result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter");
}
}

View File

@@ -47,6 +47,98 @@ class PersonServiceTest {
assertThat(personService.getById(id)).isEqualTo(person);
}
// ─── findAll ─────────────────────────────────────────────────────────────
@Test
void findAll_returnsAll_whenQueryIsNull() {
List<Person> 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<Person> 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<Person> 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();

View File

@@ -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();
}
}

View File

@@ -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<AppUser> result = userSearchService.search(null);
assertThat(result).isEmpty();
verify(userRepository, never()).searchByNameOrUsername(any(), any());
}
@Test
void search_returnsEmpty_whenQueryIsBlank() {
List<AppUser> 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<AppUser> 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));
}
}

View File

@@ -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");
}
}