feat(geschichte): add REST controller with BLOG_WRITE permission gates
GET endpoints are open to authenticated users (the service layer enforces DRAFT visibility). POST/PATCH/DELETE require @RequirePermission(BLOG_WRITE). WebMvcTest slice covers 401/403/200/201/204 paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Geschichte> 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<Geschichte> 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<Void> delete(@PathVariable UUID id) {
|
||||
geschichteService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -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("<p>x</p>")
|
||||
.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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user