|
|
|
|
@@ -0,0 +1,440 @@
|
|
|
|
|
package org.raddatz.familienarchiv.search;
|
|
|
|
|
|
|
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
|
import org.mockito.ArgumentCaptor;
|
|
|
|
|
import org.mockito.Mock;
|
|
|
|
|
import org.mockito.MockitoAnnotations;
|
|
|
|
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
|
|
|
|
import org.raddatz.familienarchiv.document.DocumentService;
|
|
|
|
|
import org.raddatz.familienarchiv.document.DocumentSort;
|
|
|
|
|
import org.raddatz.familienarchiv.document.SearchFilters;
|
|
|
|
|
import org.raddatz.familienarchiv.exception.DomainException;
|
|
|
|
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
|
|
|
import org.raddatz.familienarchiv.person.Person;
|
|
|
|
|
import org.raddatz.familienarchiv.person.PersonService;
|
|
|
|
|
import org.raddatz.familienarchiv.tag.TagOperator;
|
|
|
|
|
import org.springframework.data.domain.PageRequest;
|
|
|
|
|
import org.springframework.data.domain.Pageable;
|
|
|
|
|
|
|
|
|
|
import java.time.LocalDate;
|
|
|
|
|
import java.util.ArrayList;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.UUID;
|
|
|
|
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
|
|
|
import static org.mockito.Mockito.*;
|
|
|
|
|
|
|
|
|
|
class NlQueryParserServiceTest {
|
|
|
|
|
|
|
|
|
|
@Mock OllamaClient ollamaClient;
|
|
|
|
|
@Mock PersonService personService;
|
|
|
|
|
@Mock DocumentService documentService;
|
|
|
|
|
|
|
|
|
|
NlQueryParserService service;
|
|
|
|
|
|
|
|
|
|
static final Pageable PAGE = PageRequest.of(0, 20);
|
|
|
|
|
|
|
|
|
|
@BeforeEach
|
|
|
|
|
void setUp() {
|
|
|
|
|
MockitoAnnotations.openMocks(this);
|
|
|
|
|
service = new NlQueryParserService(ollamaClient, personService, documentService);
|
|
|
|
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
|
|
|
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
|
|
|
|
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
|
|
|
|
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Factory helpers ---
|
|
|
|
|
|
|
|
|
|
private OllamaExtraction extraction(List<String> names, String role, LocalDate from, LocalDate to,
|
|
|
|
|
List<String> keywords) {
|
|
|
|
|
String raw = names.isEmpty() ? "test query" : String.join(" ", names);
|
|
|
|
|
return new OllamaExtraction(names, role, from, to, keywords, raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Person person(UUID id, String firstName, String lastName) {
|
|
|
|
|
return Person.builder().id(id).firstName(firstName).lastName(lastName).build();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
|
|
|
|
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
|
|
|
|
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
|
|
|
|
|
|
|
|
|
// --- 1. Single resolved name + personRole=sender ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_resolvesSingleName_asSender() {
|
|
|
|
|
Person walter = person(P1, "Walter", "Raddatz");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
|
|
|
|
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
|
|
|
|
assertThat(cap.getValue().receiver()).isNull();
|
|
|
|
|
assertThat(resp.interpretation().resolvedPersons()).hasSize(1);
|
|
|
|
|
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
|
|
|
|
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 2. Multi-match name → ambiguous, search NOT executed ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_multiMatchName_populatesAmbiguous_andSkipsSearch() {
|
|
|
|
|
Person a = person(UUID.randomUUID(), "Walter", "Braun");
|
|
|
|
|
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(a, b));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
|
|
|
|
|
|
|
|
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
|
|
|
|
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
|
|
|
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
|
|
|
|
assertThat(resp.interpretation().resolvedPersons()).isEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 3. Multi-match + personRole=any → still ambiguous, search NOT executed ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() {
|
|
|
|
|
Person a = person(UUID.randomUUID(), "Emma", "Braun");
|
|
|
|
|
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(a, b));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Briefe an Emma", PAGE);
|
|
|
|
|
|
|
|
|
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
|
|
|
|
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
|
|
|
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 4. No-match name → folded into text ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_noMatchName_isFoldedIntoText() {
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
|
|
|
|
|
|
|
|
|
service.search("Briefe von Karl", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().text()).contains("Karl");
|
|
|
|
|
assertThat(cap.getValue().sender()).isNull();
|
|
|
|
|
assertThat(cap.getValue().receiver()).isNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 5. personRole=any + 1 resolved → searchDocumentsByPersonId called ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() {
|
|
|
|
|
Person walter = person(P1, "Walter", "Raddatz");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
|
|
|
|
|
|
|
|
|
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
|
|
|
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
|
|
|
|
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 6. 2 names both resolve → sender=person1, receiver=person2 ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_twoNamesResolve_assignsSenderAndReceiver() {
|
|
|
|
|
Person walter = person(P1, "Walter", "Raddatz");
|
|
|
|
|
Person emma = person(P2, "Emma", "Raddatz");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
|
|
|
|
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
|
|
|
|
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
|
|
|
|
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
|
|
|
|
assertThat(resp.interpretation().resolvedPersons().get(1).id()).isEqualTo(P2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 7. 2 names, first resolves, second ambiguous → search NOT executed ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_twoNames_secondAmbiguous_skipsSearch() {
|
|
|
|
|
Person walter = person(P1, "Walter", "Raddatz");
|
|
|
|
|
Person emma1 = person(P2, "Emma", "Braun");
|
|
|
|
|
Person emma2 = person(P3, "Emma", "Schmidt");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma1, emma2));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
|
|
|
|
|
|
|
|
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
|
|
|
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 8. 2 names, first no match → folded into text, second used as single person ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() {
|
|
|
|
|
Person emma = person(P2, "Emma", "Raddatz");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
|
|
|
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
|
|
|
|
|
|
|
|
|
service.search("Briefe von Karl an Emma", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().text()).contains("Karl");
|
|
|
|
|
assertThat(cap.getValue().sender()).isEqualTo(P2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 9. 3+ names all resolve → first two as sender/receiver, third folded into text ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_threeNamesResolve_extraFoldedIntoText() {
|
|
|
|
|
Person walter = person(P1, "Walter", "Raddatz");
|
|
|
|
|
Person emma = person(P2, "Emma", "Raddatz");
|
|
|
|
|
Person heinrich = person(P3, "Heinrich", "Braun");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Heinrich")).thenReturn(List.of(heinrich));
|
|
|
|
|
|
|
|
|
|
service.search("Briefe von Walter an Emma über Heinrich", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
|
|
|
|
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
|
|
|
|
assertThat(cap.getValue().text()).contains("Heinrich");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 10. Keywords space-joined into text ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_keywords_areJoinedIntoText() {
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter")));
|
|
|
|
|
|
|
|
|
|
service.search("Dokumente über den Krieg Walter", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().text()).isEqualTo("Krieg Walter");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 11. Date range passed through ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_dateRange_passedIntoSearchFilters() {
|
|
|
|
|
LocalDate from = LocalDate.of(1914, 1, 1);
|
|
|
|
|
LocalDate to = LocalDate.of(1914, 12, 31);
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of(), "any", from, to, List.of()));
|
|
|
|
|
|
|
|
|
|
service.search("Briefe aus dem Jahr 1914", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().from()).isEqualTo(from);
|
|
|
|
|
assertThat(cap.getValue().to()).isEqualTo(to);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 12. Null dates → null in SearchFilters (not an error) ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_nullDates_passedAsNullIntoFilters() {
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
|
|
|
|
|
|
|
|
|
service.search("Hochzeitsbriefe", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().from()).isNull();
|
|
|
|
|
assertThat(cap.getValue().to()).isNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 13. Query under 3 chars → VALIDATION_ERROR before Ollama call ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_queryTooShort_throwsValidationError() {
|
|
|
|
|
assertThatThrownBy(() -> service.search("ab", PAGE))
|
|
|
|
|
.isInstanceOf(DomainException.class)
|
|
|
|
|
.extracting(e -> ((DomainException) e).getCode())
|
|
|
|
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
|
|
|
|
|
|
|
|
|
verify(ollamaClient, never()).parse(anyString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 14. Query over 500 chars → VALIDATION_ERROR ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_queryTooLong_throwsValidationError() {
|
|
|
|
|
String longQuery = "a".repeat(501);
|
|
|
|
|
assertThatThrownBy(() -> service.search(longQuery, PAGE))
|
|
|
|
|
.isInstanceOf(DomainException.class)
|
|
|
|
|
.extracting(e -> ((DomainException) e).getCode())
|
|
|
|
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
|
|
|
|
|
|
|
|
|
verify(ollamaClient, never()).parse(anyString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 15. Ollama returns empty names/keywords → raw query used as keyword fallback ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_ollamaReturnsEmpty_usesRawQueryAsTextFallback() {
|
|
|
|
|
String raw = "Briefe aus dem Krieg";
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of(), raw));
|
|
|
|
|
|
|
|
|
|
service.search(raw, PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().text()).isEqualTo(raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 16. Null personNames/keywords from Ollama → no NPE ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_nullPersonNamesAndKeywords_handledWithoutNpe() {
|
|
|
|
|
OllamaExtraction ext = new OllamaExtraction(null, "any", null, null, null, "test query");
|
|
|
|
|
when(ollamaClient.parse(anyString())).thenReturn(ext);
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("test query", PAGE);
|
|
|
|
|
|
|
|
|
|
assertThat(resp).isNotNull();
|
|
|
|
|
verify(documentService).searchDocuments(any(), any(), any(), any());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 17. Unrecognized personRole → defaults to any-like behavior (no crash) ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() {
|
|
|
|
|
Person walter = person(P1, "Walter", "Raddatz");
|
|
|
|
|
// OllamaClient defensive parsing returns "any" for unknown roles,
|
|
|
|
|
// but NlQueryParserService must also be safe if something unexpected arrives.
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
|
|
|
|
|
|
|
|
|
// Should not crash; "unknown_role" treated as fallback (neither sender nor receiver → any)
|
|
|
|
|
assertThat(resp).isNotNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 18. Ollama throws SMART_SEARCH_UNAVAILABLE → propagates to caller ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_ollamaThrowsUnavailable_propagates() {
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline"));
|
|
|
|
|
|
|
|
|
|
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", PAGE))
|
|
|
|
|
.isInstanceOf(DomainException.class)
|
|
|
|
|
.extracting(e -> ((DomainException) e).getCode())
|
|
|
|
|
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 19. LLM-extracted name > 200 chars → skipped, PersonService never called ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() {
|
|
|
|
|
String longName = "A".repeat(201);
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of(longName), "sender", null, null, List.of()));
|
|
|
|
|
|
|
|
|
|
service.search("Briefe von sehr langem Namen", PAGE);
|
|
|
|
|
|
|
|
|
|
verify(personService, never()).findByDisplayNameContaining(anyString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_elevenCandidates_capsAtTen() {
|
|
|
|
|
List<Person> eleven = new ArrayList<>();
|
|
|
|
|
for (int i = 0; i < 11; i++) {
|
|
|
|
|
eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
|
|
|
|
}
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(eleven);
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
|
|
|
|
|
|
|
|
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(10);
|
|
|
|
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 21. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_searchFiltersDefaults_areCorrect() {
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg")));
|
|
|
|
|
|
|
|
|
|
service.search("Dokumente über den Krieg", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
|
|
|
|
SearchFilters f = cap.getValue();
|
|
|
|
|
assertThat(f.tagOperator()).isEqualTo(TagOperator.AND);
|
|
|
|
|
assertThat(f.status()).isNull();
|
|
|
|
|
assertThat(f.undated()).isFalse();
|
|
|
|
|
assertThat(f.tags()).isEmpty();
|
|
|
|
|
assertThat(f.tagQ()).isNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 22. personRole=receiver + 1 resolved → receiver UUID set ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_personRoleReceiver_singleMatch_setsReceiver() {
|
|
|
|
|
Person emma = person(P2, "Emma", "Raddatz");
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
|
|
|
|
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
|
|
|
|
|
|
|
|
|
service.search("Briefe an Emma", PAGE);
|
|
|
|
|
|
|
|
|
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
|
|
|
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
|
|
|
|
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
|
|
|
|
assertThat(cap.getValue().sender()).isNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 23. keywordsApplied=true when text is non-blank ---
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void search_keywordsApplied_trueWhenTextNonBlank() {
|
|
|
|
|
when(ollamaClient.parse(anyString()))
|
|
|
|
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
|
|
|
|
|
|
|
|
|
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", PAGE);
|
|
|
|
|
|
|
|
|
|
assertThat(resp.interpretation().keywordsApplied()).isTrue();
|
|
|
|
|
}
|
|
|
|
|
}
|