diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/GeschichteController.java new file mode 100644 index 00000000..77e81326 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/GeschichteController.java @@ -0,0 +1,65 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO; +import org.raddatz.familienarchiv.model.Geschichte; +import org.raddatz.familienarchiv.model.GeschichteStatus; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.GeschichteService; +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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/geschichten") +@RequiredArgsConstructor +public class GeschichteController { + + private final GeschichteService geschichteService; + + @GetMapping + public List list( + @RequestParam(required = false) GeschichteStatus status, + @RequestParam(required = false) UUID personId, + @RequestParam(required = false) UUID documentId, + @RequestParam(required = false, defaultValue = "50") int limit) { + return geschichteService.list(status, personId, documentId, limit); + } + + @GetMapping("/{id}") + public Geschichte getById(@PathVariable UUID id) { + return geschichteService.getById(id); + } + + @PostMapping + @RequirePermission(Permission.BLOG_WRITE) + public ResponseEntity create(@RequestBody GeschichteUpdateDTO dto) { + Geschichte created = geschichteService.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PatchMapping("/{id}") + @RequirePermission(Permission.BLOG_WRITE) + public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) { + return geschichteService.update(id, dto); + } + + @DeleteMapping("/{id}") + @RequirePermission(Permission.BLOG_WRITE) + public ResponseEntity delete(@PathVariable UUID id) { + geschichteService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/GeschichteControllerTest.java new file mode 100644 index 00000000..9ae9c457 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/GeschichteControllerTest.java @@ -0,0 +1,221 @@ +package org.raddatz.familienarchiv.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Geschichte; +import org.raddatz.familienarchiv.model.GeschichteStatus; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.GeschichteService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +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; + +@WebMvcTest(GeschichteController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class GeschichteControllerTest { + + @Autowired + MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean + GeschichteService geschichteService; + + @MockitoBean + CustomUserDetailsService customUserDetailsService; + + // ─── GET /api/geschichten ──────────────────────────────────────────────── + + @Test + void list_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/geschichten")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void list_returns200_forReader() throws Exception { + when(geschichteService.list(any(), any(), any(), anyInt())) + .thenReturn(List.of(published(UUID.randomUUID(), "Story A"))); + + mockMvc.perform(get("/api/geschichten")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("Story A")); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void list_passesPersonIdFilterToService() throws Exception { + UUID personId = UUID.randomUUID(); + when(geschichteService.list(any(), eq(personId), any(), anyInt())) + .thenReturn(List.of()); + + mockMvc.perform(get("/api/geschichten").param("personId", personId.toString())) + .andExpect(status().isOk()); + + verify(geschichteService).list(any(), eq(personId), any(), anyInt()); + } + + // ─── GET /api/geschichten/{id} ─────────────────────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void getById_returns200_whenFound() throws Exception { + UUID id = UUID.randomUUID(); + when(geschichteService.getById(id)).thenReturn(published(id, "Hello")); + + mockMvc.perform(get("/api/geschichten/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id.toString())) + .andExpect(jsonPath("$.title").value("Hello")); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getById_returns404_whenServiceThrowsNotFound() throws Exception { + UUID id = UUID.randomUUID(); + when(geschichteService.getById(id)) + .thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x")); + + mockMvc.perform(get("/api/geschichten/{id}", id)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GESCHICHTE_NOT_FOUND")); + } + + // ─── POST /api/geschichten ─────────────────────────────────────────────── + + @Test + void create_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/geschichten") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"x\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void create_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(post("/api/geschichten") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"x\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void create_returns201_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + when(geschichteService.create(any(GeschichteUpdateDTO.class))) + .thenReturn(draft(id, "New")); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("New"); + + mockMvc.perform(post("/api/geschichten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(id.toString())); + } + + // ─── PATCH /api/geschichten/{id} ───────────────────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void update_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void update_returns200_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) + .thenReturn(published(id, "Updated")); + + mockMvc.perform(patch("/api/geschichten/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PUBLISHED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PUBLISHED")); + } + + // ─── DELETE /api/geschichten/{id} ──────────────────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void delete_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void delete_returns204_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + + mockMvc.perform(delete("/api/geschichten/{id}", id)) + .andExpect(status().isNoContent()); + + verify(geschichteService).delete(id); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private Geschichte published(UUID id, String title) { + return Geschichte.builder() + .id(id) + .title(title) + .body("

x

") + .status(GeschichteStatus.PUBLISHED) + .publishedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .persons(new HashSet<>()) + .documents(new HashSet<>()) + .build(); + } + + private Geschichte draft(UUID id, String title) { + return Geschichte.builder() + .id(id) + .title(title) + .status(GeschichteStatus.DRAFT) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .persons(new HashSet<>()) + .documents(new HashSet<>()) + .build(); + } +}