diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java index 3f38cddc..0d2a173b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java @@ -78,4 +78,8 @@ public class DomainException extends RuntimeException { public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) { return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds); } + + public static DomainException serviceUnavailable(ErrorCode code, String message) { + return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java new file mode 100644 index 00000000..c58fff38 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java @@ -0,0 +1,28 @@ +package org.raddatz.familienarchiv.search; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/search/nl") +@RequiredArgsConstructor +public class NlSearchController { + + private final NlQueryParserService nlQueryParserService; + private final NlSearchRateLimiter rateLimiter; + + @PostMapping + @RequirePermission(Permission.READ_ALL) + public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request, + Pageable pageable, + @AuthenticationPrincipal UserDetails principal) { + rateLimiter.checkAndConsume(principal.getUsername()); + return nlQueryParserService.search(request.query(), pageable); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java index 5b86eaf3..64f08554 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java @@ -88,7 +88,7 @@ public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient throw e; } catch (Exception e) { log.warn("Ollama inference failed: {}", e.getClass().getSimpleName()); - throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, + throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "Ollama unavailable: " + e.getClass().getSimpleName()); } } @@ -114,7 +114,7 @@ public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient return toExtraction(raw, rawQuery); } catch (Exception e) { log.warn("Failed to parse Ollama response: {}", e.getClass().getSimpleName()); - throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, + throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "Failed to parse Ollama response: " + e.getClass().getSimpleName()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java new file mode 100644 index 00000000..b35b1c52 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java @@ -0,0 +1,161 @@ +package org.raddatz.familienarchiv.search; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.document.DocumentSearchResult; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +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; +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.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(NlSearchController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class, + NlSearchRateLimiter.class, NlSearchRateLimitProperties.class}) +class NlSearchControllerTest { + + @Autowired MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean NlQueryParserService nlQueryParserService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + @Autowired NlSearchRateLimiter rateLimiter; + + @BeforeEach + void resetRateLimiter() { + rateLimiter.resetForTest(); + } + + private NlSearchResponse makeResponse() { + PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz"); + NlQueryInterpretation interp = new NlQueryInterpretation( + List.of(hint), List.of(), null, null, + List.of("Krieg"), "Briefe von Walter im Krieg", true); + return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); + } + + // --- 1. Happy path --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns200_withNlSearchResponse() throws Exception { + when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse()); + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter im Krieg\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg")) + .andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz")) + .andExpect(jsonPath("$.interpretation.keywordsApplied").value(true)); + } + + // --- 2. ambiguousPersons in response shape --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns200_withAmbiguousPersons() throws Exception { + PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun"); + PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt"); + NlQueryInterpretation interp = new NlQueryInterpretation( + List.of(), List.of(a, b), null, null, + List.of(), "Briefe von Walter", false); + NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); + when(nlQueryParserService.search(anyString(), any())).thenReturn(resp); + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray()) + .andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun")) + .andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty()); + } + + // --- 3. Unauthenticated → 401 --- + + @Test + void search_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isUnauthorized()); + } + + // --- 4. Query < 3 chars → 400 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns400_whenQueryTooShort() throws Exception { + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"ab\"}")) + .andExpect(status().isBadRequest()); + } + + // --- 5. Query > 500 chars → 400 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns400_whenQueryTooLong() throws Exception { + String longQuery = "a".repeat(501); + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"" + longQuery + "\"}")) + .andExpect(status().isBadRequest()); + } + + // --- 6. Ollama unavailable → 503 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns503_whenOllamaUnavailable() throws Exception { + when(nlQueryParserService.search(anyString(), any())) + .thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "Ollama offline")); + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE")); + } + + // --- 7. 6th request in 1 minute → 429 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns429_onSixthRequestWithinRateLimit() throws Exception { + when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse()); + + for (int i = 0; i < 5; i++) { + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isOk()); + } + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED")); + } +}