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