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:
Marcel
2026-05-02 17:31:43 +02:00
parent 08d96e5b0f
commit 9fc96a15cf
2 changed files with 286 additions and 0 deletions

View File

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

View File

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