feat(#248): add POST /api/tags/{id}/merge and DELETE /api/tags/{id}/subtree endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 22:27:41 +02:00
parent 609d242f5d
commit 5e5c249aba
2 changed files with 102 additions and 0 deletions

View File

@@ -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<TagTreeNodeDTO> getTagTree() {
return tagService.getTagTree();
}
@PostMapping("/{id}/merge")
@RequirePermission(Permission.ADMIN_TAG)
public ResponseEntity<Tag> 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);
}
}

View File

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