feat(search): implement NlSearchController with @WebMvcTest tests (7 cases)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,4 +78,8 @@ public class DomainException extends RuntimeException {
|
|||||||
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient
|
|||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Ollama inference failed: {}", e.getClass().getSimpleName());
|
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());
|
"Ollama unavailable: " + e.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient
|
|||||||
return toExtraction(raw, rawQuery);
|
return toExtraction(raw, rawQuery);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to parse Ollama response: {}", e.getClass().getSimpleName());
|
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());
|
"Failed to parse Ollama response: " + e.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user