From afd1f0b86b956b31ebbcd4894ece8cdcd40d1a86 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:22:44 +0200 Subject: [PATCH] feat(timeline): add GET /api/timeline endpoint + 8-test controller suite TimelineController exposes GET /api/timeline with @RequirePermission(READ_ALL) and @Validated so @Min(0) on generation fires a 400. Delegates to TimelineService.assemble(TimelineFilter). DomainException 404/400 propagate via GlobalExceptionHandler (no extra mapping needed). Refs #777 Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/TimelineController.java | 33 +++++ .../timeline/TimelineControllerTest.java | 139 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java new file mode 100644 index 00000000..31992a8e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java @@ -0,0 +1,33 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/timeline") +@Validated +@RequiredArgsConstructor +public class TimelineController { + + private final TimelineService timelineService; + + @GetMapping + @RequirePermission(Permission.READ_ALL) + public TimelineDTO getTimeline( + @RequestParam(required = false) UUID personId, + @RequestParam(required = false) @Min(0) Integer generation, + @RequestParam(required = false) EventType type, + @RequestParam(required = false) Integer fromYear, + @RequestParam(required = false) Integer toYear) { + return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear)); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java new file mode 100644 index 00000000..61c81496 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java @@ -0,0 +1,139 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.security.SecurityConfig; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.CustomUserDetailsService; +import org.raddatz.familienarchiv.user.UserService; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TimelineController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class TimelineControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean TimelineService timelineService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of()); + + @BeforeEach + void resolveDefaultPrincipal() { + when(userService.findByEmail("user")) + .thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build()); + } + + // ─── Security ───────────────────────────────────────────────────────────── + + @Test + void returns_401_when_unauthenticated() throws Exception { + // REQ-014 + mockMvc.perform(get("/api/timeline")) + .andExpect(status().isUnauthorized()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL") + void returns_403_when_authenticated_without_read_all() throws Exception { + // REQ-015 + mockMvc.perform(get("/api/timeline")) + .andExpect(status().isForbidden()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_200_with_read_all_permission() throws Exception { + // REQ-001 + when(timelineService.assemble(any())).thenReturn(EMPTY); + + mockMvc.perform(get("/api/timeline")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.years").isArray()) + .andExpect(jsonPath("$.undated").isArray()); + } + + // ─── Parameter binding ──────────────────────────────────────────────────── + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void valid_params_are_forwarded_to_service() throws Exception { + UUID personId = UUID.randomUUID(); + when(timelineService.assemble(any())).thenReturn(EMPTY); + + mockMvc.perform(get("/api/timeline") + .param("personId", personId.toString()) + .param("generation", "2") + .param("type", "HISTORICAL") + .param("fromYear", "1914") + .param("toYear", "1918")) + .andExpect(status().isOk()); + + verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918)); + } + + // ─── Validation errors ──────────────────────────────────────────────────── + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_400_on_bad_type_value() throws Exception { + // REQ-018 — Spring enum binding rejects unknown value + mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE")) + .andExpect(status().isBadRequest()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_400_when_fromYear_greater_than_toYear() throws Exception { + // REQ-016 — service throws bad request, controller propagates it + when(timelineService.assemble(any())) + .thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "toYear must not be before fromYear")); + + mockMvc.perform(get("/api/timeline") + .param("fromYear", "1920") + .param("toYear", "1914")) + .andExpect(status().isBadRequest()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_400_when_generation_is_negative() throws Exception { + // REQ-017 — @Min(0) on generation parameter + mockMvc.perform(get("/api/timeline").param("generation", "-1")) + .andExpect(status().isBadRequest()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_404_when_person_not_found() throws Exception { + // REQ-019 + when(timelineService.assemble(any())) + .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); + + mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND"))); + } +}