feat(annotations): add PATCH endpoint for annotation resize/move

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-14 10:42:08 +02:00
parent 1558881c01
commit ff231db671
2 changed files with 97 additions and 0 deletions

View File

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

View File

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