diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java index 43b715d7..01471222 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; @@ -11,6 +12,7 @@ import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.UserService; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -45,6 +47,15 @@ public class AnnotationController { return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash()); } + @PatchMapping("/{annotationId}") + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) + public DocumentAnnotation updateAnnotation( + @PathVariable UUID documentId, + @PathVariable UUID annotationId, + @Valid @RequestBody UpdateAnnotationDTO dto) { + return annotationService.updateAnnotation(documentId, annotationId, dto); + } + @DeleteMapping("/{annotationId}") @ResponseStatus(HttpStatus.NO_CONTENT) @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index 4b95d877..0da8d073 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -160,6 +161,91 @@ class AnnotationControllerTest { .andExpect(status().isNoContent()); } + // ─── PATCH /api/documents/{documentId}/annotations/{annotationId} ───────── + + private static final String PATCH_JSON = "{\"x\":0.2,\"y\":0.3}"; + + @Test + void patchAnnotation_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void patchAnnotation_returns403_withoutPermission() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns200_withWriteAllPermission() throws Exception { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation updated = DocumentAnnotation.builder() + .id(annotId).documentId(docId).pageNumber(1) + .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); + when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.x").value(0.2)) + .andExpect(jsonPath("$.y").value(0.3)); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void patchAnnotation_returns200_withAnnotateAllPermission() throws Exception { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation updated = DocumentAnnotation.builder() + .id(annotId).documentId(docId).pageNumber(1) + .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); + when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns404_whenAnnotationBelongsToDifferentDocument() throws Exception { + when(annotationService.updateAnnotation(any(), any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found")); + + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"x\":-0.1,\"y\":0.3}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"width\":0.005}")) + .andExpect(status().isBadRequest()); + } + // ─── resolveUserId — unauthenticated / null user / exception branches ───── @Test