From a0fa8f4d02245e04a7ba3c56bed25cc350eb27d8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 17:02:12 +0200 Subject: [PATCH] feat(journeyitem): add CRUD endpoints for JourneyItems on GeschichteController Adds POST/PATCH/DELETE/PUT-reorder endpoints for journey items, backed by JourneyItemService. Replaces jackson-databind-nullable (Jackson 2.x, incompatible with Spring Boot 4 / Jackson 3.x) with Optional three-way PATCH semantics: null = absent/no-op, empty = clear, present = set. Co-Authored-By: Claude Sonnet 4.6 --- backend/pom.xml | 7 - .../familienarchiv/config/JacksonConfig.java | 11 +- .../geschichte/GeschichteController.java | 44 +++++ .../journeyitem/JourneyItemService.java | 8 +- .../journeyitem/JourneyItemUpdateDTO.java | 15 +- .../geschichte/GeschichteControllerTest.java | 172 +++++++++++++++++- .../journeyitem/JourneyItemServiceTest.java | 15 +- 7 files changed, 232 insertions(+), 40 deletions(-) diff --git a/backend/pom.xml b/backend/pom.xml index 30fa0719..b01f2362 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -253,13 +253,6 @@ 1.18.1 - - - org.openapitools - jackson-databind-nullable - 0.2.6 - - io.micrometer diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java index 5c5e0945..43a597d3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java @@ -1,15 +1,10 @@ package org.raddatz.familienarchiv.config; -import com.fasterxml.jackson.databind.Module; -import org.openapitools.jackson.nullable.JsonNullableModule; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Jackson customisations. Currently a placeholder — custom modules added here as needed. + */ @Configuration public class JacksonConfig { - - @Bean - public Module jsonNullableModule() { - return new JsonNullableModule(); - } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java index 11468c8d..baf365a4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java @@ -1,6 +1,11 @@ package org.raddatz.familienarchiv.geschichte; import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.springframework.http.HttpStatus; @@ -10,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; 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; @@ -24,6 +30,7 @@ import java.util.UUID; public class GeschichteController { private final GeschichteService geschichteService; + private final JourneyItemService journeyItemService; @GetMapping public List list( @@ -60,4 +67,41 @@ public class GeschichteController { geschichteService.delete(id); return ResponseEntity.noContent().build(); } + + // ─── JourneyItem CRUD ──────────────────────────────────────────────────── + + @PostMapping("/{id}/items") + @RequirePermission(Permission.BLOG_WRITE) + public ResponseEntity appendItem( + @PathVariable UUID id, + @RequestBody JourneyItemCreateDTO dto) { + JourneyItemView view = journeyItemService.append(id, dto); + return ResponseEntity.status(HttpStatus.CREATED).body(view); + } + + @PatchMapping("/{id}/items/{itemId}") + @RequirePermission(Permission.BLOG_WRITE) + public JourneyItemView updateItemNote( + @PathVariable UUID id, + @PathVariable UUID itemId, + @RequestBody JourneyItemUpdateDTO dto) { + return journeyItemService.updateNote(id, itemId, dto); + } + + @DeleteMapping("/{id}/items/{itemId}") + @RequirePermission(Permission.BLOG_WRITE) + public ResponseEntity deleteItem( + @PathVariable UUID id, + @PathVariable UUID itemId) { + journeyItemService.delete(id, itemId); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/items/reorder") + @RequirePermission(Permission.BLOG_WRITE) + public List reorderItems( + @PathVariable UUID id, + @RequestBody JourneyReorderDTO dto) { + return journeyItemService.reorder(id, dto); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java index 6b1923f8..d0835c26 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.geschichte.journeyitem; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.openapitools.jackson.nullable.JsonNullable; import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.document.DatePrecision; @@ -98,12 +97,13 @@ public class JourneyItemService { .orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "Journey item not found: " + itemId)); - JsonNullable noteField = dto.getNote(); - if (!noteField.isPresent()) { + // null = field absent from JSON → no-op + Optional noteField = dto.getNote(); + if (noteField == null) { return toView(item); } - String note = normalizeNote(noteField.get()); + String note = normalizeNote(noteField.orElse(null)); if (note != null && note.length() > MAX_NOTE_LENGTH) { throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java index db8c4e6e..1e63ac9c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java @@ -1,16 +1,19 @@ package org.raddatz.familienarchiv.geschichte.journeyitem; import lombok.Data; -import org.openapitools.jackson.nullable.JsonNullable; + +import java.util.Optional; /** * Input for PATCH /api/geschichten/{id}/items/{itemId}. - * JsonNullable enables three-way semantics: - * absent → field not present in JSON → leave unchanged - * null → {"note": null} → clear the note field - * string → {"note": "text"} → set the note + * Three-way semantics via Optional: + * null → field absent from JSON → leave note unchanged + * Optional.empty() → {"note": null} → clear the note + * Optional.of("x") → {"note": "x"} → set the note + * + * Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null). */ @Data public class JourneyItemUpdateDTO { - private JsonNullable note = JsonNullable.undefined(); + private Optional note = null; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index 54dc8c85..88320dc2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.geschichte; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; -import org.raddatz.familienarchiv.security.SecurityConfig; +import org.raddatz.familienarchiv.config.JacksonConfig; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView; +import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.user.CustomUserDetailsService; import org.springframework.beans.factory.annotation.Autowired; @@ -32,11 +35,12 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(GeschichteController.class) -@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class, JacksonConfig.class}) class GeschichteControllerTest { @Autowired @@ -44,11 +48,9 @@ class GeschichteControllerTest { private final ObjectMapper objectMapper = new ObjectMapper(); - @MockitoBean - GeschichteService geschichteService; - - @MockitoBean - CustomUserDetailsService customUserDetailsService; + @MockitoBean GeschichteService geschichteService; + @MockitoBean JourneyItemService journeyItemService; + @MockitoBean CustomUserDetailsService customUserDetailsService; // ─── GET /api/geschichten ──────────────────────────────────────────────── @@ -205,8 +207,164 @@ class GeschichteControllerTest { verify(geschichteService).delete(id); } + // ─── POST /api/geschichten/{id}/items ──────────────────────────────────── + + @Test + void appendItem_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void appendItem_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void appendItem_returns201_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note")); + + mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"Note\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(itemId.toString())) + .andExpect(jsonPath("$.position").value(10)); + } + + // ─── PATCH /api/geschichten/{id}/items/{itemId} ────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void updateItemNote_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", + UUID.randomUUID(), UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void updateItemNote_returns200_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.updateNote(eq(id), eq(itemId), any())) + .thenReturn(itemViewStub(itemId, 10, "Updated")); + + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"Updated\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.note").value("Updated")); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void updateItemNote_null_note_deserializes_as_present_null() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.updateNote(eq(id), eq(itemId), any())) + .thenReturn(itemViewStub(itemId, 10, null)); + + // Raw JSON — local objectMapper lacks JsonNullableModule + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\": null}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.note").doesNotExist()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void updateItemNote_returns404_whenItemNotFound() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.updateNote(eq(id), eq(itemId), any())) + .thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found")); + + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND")); + } + + // ─── DELETE /api/geschichten/{id}/items/{itemId} ───────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void deleteItem_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", + UUID.randomUUID(), UUID.randomUUID()).with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void deleteItem_returns204_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + + mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())) + .andExpect(status().isNoContent()); + + verify(journeyItemService).delete(id, itemId); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void deleteItem_returns404_whenItemNotFound() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found")) + .when(journeyItemService).delete(id, itemId); + + mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND")); + } + + // ─── PUT /api/geschichten/{id}/items/reorder ───────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void reorderItems_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"itemIds\":[]}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void reorderItems_returns200_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null))); + + mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"itemIds\":[\"" + itemId + "\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(itemId.toString())); + } + // ─── helpers ───────────────────────────────────────────────────────────── + private JourneyItemView itemViewStub(UUID id, int position, String note) { + return new JourneyItemView(id, position, null, note); + } + private Geschichte published(UUID id, String title) { return Geschichte.builder() .id(id) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java index 9ffb4fc1..a6afe3b6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java @@ -6,7 +6,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.openapitools.jackson.nullable.JsonNullable; import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.document.DatePrecision; @@ -310,7 +309,7 @@ class JourneyItemServiceTest { when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); - // note is JsonNullable.undefined() by default + // note is null by default — absent from JSON, no-op JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); @@ -329,7 +328,7 @@ class JourneyItemServiceTest { when(journeyItemRepository.save(item)).thenReturn(saved); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); - dto.setNote(JsonNullable.of(null)); + dto.setNote(Optional.empty()); JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); @@ -346,7 +345,7 @@ class JourneyItemServiceTest { when(journeyItemRepository.save(item)).thenReturn(saved); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); - dto.setNote(JsonNullable.of("New note")); + dto.setNote(Optional.of("New note")); JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); @@ -360,7 +359,7 @@ class JourneyItemServiceTest { when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); - dto.setNote(JsonNullable.of(null)); + dto.setNote(Optional.empty()); assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto)) .isInstanceOf(DomainException.class) @@ -379,7 +378,7 @@ class JourneyItemServiceTest { when(journeyItemRepository.save(item)).thenReturn(saved); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); - dto.setNote(JsonNullable.of("\n \n")); + dto.setNote(Optional.of("\n \n")); JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); @@ -393,7 +392,7 @@ class JourneyItemServiceTest { when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); - dto.setNote(JsonNullable.of("x".repeat(5001))); + dto.setNote(Optional.of("x".repeat(5001))); assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto)) .isInstanceOf(DomainException.class) @@ -406,7 +405,7 @@ class JourneyItemServiceTest { when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty()); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); - dto.setNote(JsonNullable.of("text")); + dto.setNote(Optional.of("text")); assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto)) .isInstanceOf(DomainException.class)