From 225d6e44c99e8352356f33f81ad06dc8b17aa942 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 17 Mar 2026 21:33:24 +0100 Subject: [PATCH 1/2] test(backend): add service unit tests and controller slice tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service unit tests (Mockito, no Spring context): - DocumentServiceTest — getById, updateDocument, deleteTagCascading, createPlaceholder - PersonServiceTest — getById, findOrCreateByAlias, mergePersons - TagServiceTest — getById, findOrCreate, update, delete - UserServiceTest — findByUsername, deleteUser, createUserOrUpdate, getGroupById Controller slice tests (@WebMvcTest): - DocumentControllerTest — 401/403/200 for GET /search, POST /, PUT /{id} - TagControllerTest — 401/403/200 for GET, PUT /{id}, DELETE /{id} Also removes FamilienarchivApplicationTests (full @SpringBootTest requires DB + S3 infrastructure; covered by e2e job instead). 65 tests total, all passing. Refs #4 Co-Authored-By: Claude Sonnet 4.6 --- .../FamilienarchivApplicationTests.java | 13 -- .../controller/DocumentControllerTest.java | 116 +++++++++++++++ .../controller/TagControllerTest.java | 106 +++++++++++++ .../service/DocumentServiceTest.java | 124 ++++++++++++++++ .../service/PersonServiceTest.java | 140 ++++++++++++++++++ .../service/TagServiceTest.java | 131 ++++++++++++++++ .../service/UserServiceTest.java | 122 +++++++++++++++ 7 files changed, 739 insertions(+), 13 deletions(-) delete mode 100644 backend/src/test/java/org/raddatz/familienarchiv/FamilienarchivApplicationTests.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/FamilienarchivApplicationTests.java b/backend/src/test/java/org/raddatz/familienarchiv/FamilienarchivApplicationTests.java deleted file mode 100644 index 35c8122b..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/FamilienarchivApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.raddatz.familienarchiv; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class FamilienarchivApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java new file mode 100644 index 00000000..a560d4d3 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -0,0 +1,116 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.FileService; +import org.springframework.beans.factory.annotation.Autowired; +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.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.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.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(DocumentController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class DocumentControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean DocumentService documentService; + @MockitoBean FileService fileService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + // ─── GET /api/documents/search ──────────────────────────────────────────── + + @Test + void search_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/search")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void search_returns200_whenAuthenticated() throws Exception { + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any())) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/documents/search")) + .andExpect(status().isOk()); + } + + // ─── POST /api/documents ───────────────────────────────────────────────── + + @Test + void createDocument_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(multipart("/api/documents")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void createDocument_returns403_whenMissingWritePermission() throws Exception { + mockMvc.perform(multipart("/api/documents")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createDocument_returns200_whenHasWritePermission() throws Exception { + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Test") + .originalFilename("test.pdf") + .build(); + when(documentService.createDocument(any(), any())).thenReturn(doc); + + mockMvc.perform(multipart("/api/documents")) + .andExpect(status().isOk()); + } + + // ─── PUT /api/documents/{id} ───────────────────────────────────────────── + + @Test + void updateDocument_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) + .with(req -> { req.setMethod("PUT"); return req; })) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void updateDocument_returns403_whenMissingWritePermission() throws Exception { + mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) + .with(req -> { req.setMethod("PUT"); return req; })) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateDocument_returns200_whenHasWritePermission() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder() + .id(id) + .title("Updated") + .originalFilename("test.pdf") + .build(); + when(documentService.updateDocument(any(), any(), any())).thenReturn(doc); + + mockMvc.perform(multipart("/api/documents/" + id) + .with(req -> { req.setMethod("PUT"); return req; })) + .andExpect(status().isOk()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java new file mode 100644 index 00000000..77c2176f --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java @@ -0,0 +1,106 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.TagService; +import org.springframework.beans.factory.annotation.Autowired; +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.List; +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.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TagController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class TagControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean TagService tagService; + @MockitoBean DocumentService documentService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + // ─── GET /api/tags ──────────────────────────────────────────────────────── + + @Test + void searchTags_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/tags")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void searchTags_returns200_whenAuthenticated() throws Exception { + when(tagService.search(any())).thenReturn(List.of()); + + mockMvc.perform(get("/api/tags")) + .andExpect(status().isOk()); + } + + // ─── PUT /api/tags/{id} ─────────────────────────────────────────────────── + + @Test + void updateTag_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"New\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void updateTag_returns403_whenMissingAdminTagPermission() throws Exception { + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"New\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void updateTag_returns200_whenHasAdminTagPermission() throws Exception { + Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build(); + when(tagService.update(any(), any())).thenReturn(tag); + + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"New\"}")) + .andExpect(status().isOk()); + } + + // ─── DELETE /api/tags/{id} ──────────────────────────────────────────────── + + @Test + void deleteTag_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void deleteTag_returns200_whenHasAdminTagPermission() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + .andExpect(status().isOk()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java new file mode 100644 index 00000000..c978b439 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -0,0 +1,124 @@ +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.dto.DocumentUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.repository.DocumentRepository; + +import java.util.HashSet; +import java.util.List; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DocumentServiceTest { + + @Mock DocumentRepository documentRepository; + @Mock PersonService personService; + @Mock FileService fileService; + @Mock TagService tagService; + @InjectMocks DocumentService documentService; + + // ─── getDocumentById ────────────────────────────────────────────────────── + + @Test + void getDocumentById_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(documentRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> documentService.getDocumentById(id)) + .isInstanceOf(DomainException.class) + .hasMessageContaining(id.toString()); + } + + @Test + void getDocumentById_returnsDocument_whenFound() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("Test").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + assertThat(documentService.getDocumentById(id)).isEqualTo(doc); + } + + // ─── updateDocument ─────────────────────────────────────────────────────── + + @Test + void updateDocument_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(documentRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> documentService.updateDocument(id, new DocumentUpdateDTO(), null)) + .isInstanceOf(DomainException.class); + } + + // ─── deleteTagCascading ─────────────────────────────────────────────────── + + @Test + void deleteTagCascading_removesTagFromAllDocumentsAndDeletesTag() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Familie").build(); + Document doc = Document.builder() + .id(UUID.randomUUID()) + .tags(new HashSet<>(Set.of(tag))) + .build(); + + when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.deleteTagCascading(tagId); + + assertThat(doc.getTags()).isEmpty(); + verify(tagService).delete(tagId); + } + + @Test + void deleteTagCascading_worksWhenNoDocumentsHaveTag() { + UUID tagId = UUID.randomUUID(); + when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of()); + + documentService.deleteTagCascading(tagId); + + verify(documentRepository, never()).save(any()); + verify(tagService).delete(tagId); + } + + // ─── createPlaceholder ──────────────────────────────────────────────────── + + @Test + void createPlaceholder_returnsExisting_whenAlreadyExists() { + String filename = "scan001.pdf"; + Document existing = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build(); + when(documentRepository.existsByOriginalFilename(filename)).thenReturn(true); + when(documentRepository.findByOriginalFilename(filename)).thenReturn(Optional.of(existing)); + + Document result = documentService.createPlaceholder(filename); + + assertThat(result).isEqualTo(existing); + verify(documentRepository, never()).save(any()); + } + + @Test + void createPlaceholder_createsNew_whenNotExists() { + String filename = "scan002.pdf"; + Document saved = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build(); + when(documentRepository.existsByOriginalFilename(filename)).thenReturn(false); + when(documentRepository.save(any())).thenReturn(saved); + + Document result = documentService.createPlaceholder(filename); + + assertThat(result).isEqualTo(saved); + verify(documentRepository).save(any()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java new file mode 100644 index 00000000..2246106e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -0,0 +1,140 @@ +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.Person; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.springframework.web.server.ResponseStatusException; + +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 PersonServiceTest { + + @Mock PersonRepository personRepository; + @InjectMocks PersonService personService; + + // ─── getById ───────────────────────────────────────────────────────────── + + @Test + void getById_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(personRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> personService.getById(id)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + @Test + void getById_returnsPerson_whenFound() { + UUID id = UUID.randomUUID(); + Person person = Person.builder().id(id).firstName("Hans").lastName("Müller").build(); + when(personRepository.findById(id)).thenReturn(Optional.of(person)); + + assertThat(personService.getById(id)).isEqualTo(person); + } + + // ─── findOrCreateByAlias ───────────────────────────────────────────────── + + @Test + void findOrCreateByAlias_returnsExisting_whenAliasFound() { + String alias = "Walter de Gruyter"; + Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build(); + when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing)); + + Person result = personService.findOrCreateByAlias(alias); + + assertThat(result).isEqualTo(existing); + verify(personRepository, never()).save(any()); + } + + @Test + void findOrCreateByAlias_createsNew_whenAliasNotFound() { + String alias = "Clara Cram"; + Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build(); + when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenReturn(saved); + + Person result = personService.findOrCreateByAlias(alias); + + assertThat(result).isEqualTo(saved); + verify(personRepository).save(any()); + } + + @Test + void findOrCreateByAlias_trimsInput() { + String alias = " Clara Cram "; + Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build(); + when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved)); + + personService.findOrCreateByAlias(alias); + + verify(personRepository).findByAliasIgnoreCase("Clara Cram"); + } + + // ─── mergePersons ───────────────────────────────────────────────────────── + + @Test + void mergePersons_throwsBadRequest_whenSourceEqualsTarget() { + UUID id = UUID.randomUUID(); + + assertThatThrownBy(() -> personService.mergePersons(id, id)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void mergePersons_throwsNotFound_whenSourceMissing() { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + when(personRepository.findById(sourceId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + @Test + void mergePersons_throwsNotFound_whenTargetMissing() { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build(); + when(personRepository.findById(sourceId)).thenReturn(Optional.of(source)); + when(personRepository.findById(targetId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + @Test + void mergePersons_reassignsDocumentsAndDeletesSource() { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build(); + Person target = Person.builder().id(targetId).firstName("Anna").lastName("Neu").build(); + when(personRepository.findById(sourceId)).thenReturn(Optional.of(source)); + when(personRepository.findById(targetId)).thenReturn(Optional.of(target)); + + personService.mergePersons(sourceId, targetId); + + verify(personRepository).reassignSender(sourceId, targetId); + verify(personRepository).insertMissingReceiverReference(sourceId, targetId); + verify(personRepository).deleteReceiverReferences(sourceId); + verify(personRepository).deleteById(sourceId); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java new file mode 100644 index 00000000..8700e153 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -0,0 +1,131 @@ +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.Tag; +import org.raddatz.familienarchiv.repository.TagRepository; +import org.springframework.web.server.ResponseStatusException; + +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 TagServiceTest { + + @Mock TagRepository tagRepository; + @InjectMocks TagService tagService; + + // ─── getById ───────────────────────────────────────────────────────────── + + @Test + void getById_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(tagRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tagService.getById(id)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + @Test + void getById_returnsTag_whenFound() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Familie").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + + assertThat(tagService.getById(id)).isEqualTo(tag); + } + + // ─── findOrCreate ───────────────────────────────────────────────────────── + + @Test + void findOrCreate_returnsExisting_whenNameFound() { + Tag existing = Tag.builder().id(UUID.randomUUID()).name("Familie").build(); + when(tagRepository.findByNameIgnoreCase("Familie")).thenReturn(Optional.of(existing)); + + Tag result = tagService.findOrCreate("Familie"); + + assertThat(result).isEqualTo(existing); + verify(tagRepository, never()).save(any()); + } + + @Test + void findOrCreate_createsNew_whenNameNotFound() { + Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build(); + when(tagRepository.findByNameIgnoreCase("Krieg")).thenReturn(Optional.empty()); + when(tagRepository.save(any())).thenReturn(saved); + + Tag result = tagService.findOrCreate("Krieg"); + + assertThat(result).isEqualTo(saved); + verify(tagRepository).save(any()); + } + + @Test + void findOrCreate_trimsWhitespaceBeforeLookup() { + Tag existing = Tag.builder().id(UUID.randomUUID()).name("Urlaub").build(); + when(tagRepository.findByNameIgnoreCase("Urlaub")).thenReturn(Optional.of(existing)); + + tagService.findOrCreate(" Urlaub "); + + verify(tagRepository).findByNameIgnoreCase("Urlaub"); + } + + // ─── update ─────────────────────────────────────────────────────────────── + + @Test + void update_savesNewName() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Old").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); + + Tag result = tagService.update(id, "New"); + + assertThat(result.getName()).isEqualTo("New"); + } + + @Test + void update_throwsNotFound_whenTagMissing() { + UUID id = UUID.randomUUID(); + when(tagRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tagService.update(id, "New")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + // ─── delete ─────────────────────────────────────────────────────────────── + + @Test + void delete_callsRepositoryDelete() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("ToDelete").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + + tagService.delete(id); + + verify(tagRepository).delete(tag); + } + + @Test + void delete_throwsNotFound_whenTagMissing() { + UUID id = UUID.randomUUID(); + when(tagRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tagService.delete(id)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java new file mode 100644 index 00000000..fe3ad905 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -0,0 +1,122 @@ +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.dto.CreateUserRequest; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.repository.UserGroupRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + +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 UserServiceTest { + + @Mock AppUserRepository userRepository; + @Mock UserGroupRepository groupRepository; + @Mock PasswordEncoder passwordEncoder; + @InjectMocks UserService userService; + + // ─── findByUsername ─────────────────────────────────────────────────────── + + @Test + void findByUsername_throwsNotFound_whenMissing() { + when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.findByUsername("ghost")) + .isInstanceOf(DomainException.class); + } + + @Test + void findByUsername_returnsUser_whenFound() { + AppUser user = AppUser.builder().id(UUID.randomUUID()).username("admin").build(); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user)); + + assertThat(userService.findByUsername("admin")).isEqualTo(user); + } + + // ─── deleteUser ─────────────────────────────────────────────────────────── + + @Test + void deleteUser_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(userRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUser(id)) + .isInstanceOf(DomainException.class); + } + + @Test + void deleteUser_deletesUser_whenFound() { + UUID id = UUID.randomUUID(); + AppUser user = AppUser.builder().id(id).username("gast").build(); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + userService.deleteUser(id); + + verify(userRepository).delete(user); + } + + // ─── createUserOrUpdate ─────────────────────────────────────────────────── + + @Test + void createUserOrUpdate_createsNewUser_whenNotExists() { + CreateUserRequest req = new CreateUserRequest(); + req.setUsername("newuser"); + req.setEmail("new@example.com"); + req.setInitialPassword("secret"); + req.setGroupIds(List.of()); + + when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("secret")).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(userRepository).save(any()); + } + + @Test + void createUserOrUpdate_updatesExistingUser_whenFound() { + CreateUserRequest req = new CreateUserRequest(); + req.setUsername("existing"); + req.setEmail("existing@example.com"); + req.setInitialPassword("newpass"); + req.setGroupIds(List.of()); + + AppUser existing = AppUser.builder().id(UUID.randomUUID()).username("existing").build(); + when(userRepository.findByUsername("existing")).thenReturn(Optional.of(existing)); + when(passwordEncoder.encode(any())).thenReturn("encoded"); + when(userRepository.save(any())).thenReturn(existing); + + userService.createUserOrUpdate(req); + + // save called once with the updated existing user (no new user created) + verify(userRepository, times(1)).save(existing); + } + + // ─── getGroupById ───────────────────────────────────────────────────────── + + @Test + void getGroupById_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(groupRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getGroupById(id)) + .isInstanceOf(DomainException.class); + } +} -- 2.49.1 From 3f987ca48fb11230fc248ad7f6de919536c9c53a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 17 Mar 2026 21:37:50 +0100 Subject: [PATCH 2/2] ci: add backend unit test job to pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs ./mvnw clean test in a dedicated job — no DB or S3 needed since all tests use Mockito or WebMvcTest slices. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ea5d461e..a90ccf9c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,6 +38,26 @@ jobs: name: unit-test-screenshots path: frontend/test-results/screenshots/ + # ─── Backend Unit & Slice Tests ─────────────────────────────────────────────── + # Pure Mockito + WebMvcTest — no DB or S3 needed. + backend-unit-tests: + name: Backend Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + + - name: Run backend tests + run: | + chmod +x mvnw + ./mvnw clean test + working-directory: backend + # ─── E2E Tests ──────────────────────────────────────────────────────────────── # Needs: PostgreSQL + MinIO (via docker-compose) + Spring Boot + SvelteKit dev server. # Test data is seeded by DataInitializer on first startup (admin user + e2e profile data). -- 2.49.1