From 5e5c249abafda86d4a37e993a8b1b356d66ca07a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 22:27:41 +0200 Subject: [PATCH] feat(#248): add POST /api/tags/{id}/merge and DELETE /api/tags/{id}/subtree endpoints Co-Authored-By: Claude Sonnet 4.6 --- .../controller/TagController.java | 20 +++++ .../controller/TagControllerTest.java | 82 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java index 71bf1263..46ab77f8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller; import java.util.List; import java.util.UUID; +import org.raddatz.familienarchiv.dto.MergeTagDTO; import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; import org.raddatz.familienarchiv.dto.TagUpdateDTO; import org.raddatz.familienarchiv.model.Tag; @@ -10,15 +11,19 @@ import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.TagService; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; @@ -52,4 +57,19 @@ public class TagController { public List getTagTree() { return tagService.getTagTree(); } + + @PostMapping("/{id}/merge") + @RequirePermission(Permission.ADMIN_TAG) + public ResponseEntity mergeTag(@PathVariable UUID id, @RequestBody MergeTagDTO dto) { + if (dto.targetId() == null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "targetId required"); + return ResponseEntity.ok(tagService.mergeTags(id, dto.targetId())); + } + + @DeleteMapping("/{id}/subtree") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.ADMIN_TAG) + public void deleteSubtree(@PathVariable UUID id) { + tagService.deleteWithDescendants(id); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java index 0ce9f9ab..dbc524ca 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java @@ -20,7 +20,12 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.UUID; +import org.raddatz.familienarchiv.dto.MergeTagDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; + import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -107,6 +112,83 @@ class TagControllerTest { .andExpect(jsonPath("$[0].children[0].name").value("Haus")); } + // ─── POST /api/tags/{id}/merge ──────────────────────────────────────────── + + @Test + void mergeTag_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception { + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void mergeTag_returns400_whenTargetIdIsNull() throws Exception { + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void mergeTag_returns404_whenSourceTagNotFound() throws Exception { + when(tagService.mergeTags(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found")); + + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void mergeTag_returns200_withTargetTag_onSuccess() throws Exception { + UUID targetId = UUID.randomUUID(); + Tag target = Tag.builder().id(targetId).name("Target").build(); + when(tagService.mergeTags(any(), any())).thenReturn(target); + + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + targetId + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(targetId.toString())) + .andExpect(jsonPath("$.name").value("Target")); + } + + // ─── DELETE /api/tags/{id}/subtree ──────────────────────────────────────── + + @Test + void deleteSubtree_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + .andExpect(status().isNoContent()); + } + // ─── DELETE /api/tags/{id} ──────────────────────────────────────────────── @Test