test(search): replace OllamaClient test suite with NlpClient equivalents

- Delete RestClientOllamaClientTest, add RestClientNlpClientTest:
  WireMock targets POST /parse; adds isHealthy_returnsFalse_whenPersonsLoadedIsZero
- NlQueryParserServiceTest: @Mock NlpClient; all stubs updated to parse(String,String);
  NlpExtraction throughout; service.search(..., "de", PAGE); adds verify(nlpClient).parse(eq,eq)
- NlSearchControllerTest: add lang:"de" to all request bodies; stubs use anyString×3;
  rename search_returns503_whenOllamaUnavailable → search_returns503_whenNlpServiceUnavailable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-07 16:04:50 +02:00
committed by marcel
parent 28201e363a
commit 503ed6adef
3 changed files with 248 additions and 152 deletions

View File

@@ -33,7 +33,7 @@ import static org.mockito.Mockito.*;
class NlQueryParserServiceTest {
@Mock OllamaClient ollamaClient;
@Mock NlpClient nlpClient;
@Mock PersonService personService;
@Mock DocumentService documentService;
@Mock TagService tagService;
@@ -45,7 +45,7 @@ class NlQueryParserServiceTest {
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
service = new NlQueryParserService(ollamaClient, personService, documentService, tagService);
service = new NlQueryParserService(nlpClient, personService, documentService, tagService);
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
@@ -55,10 +55,10 @@ class NlQueryParserServiceTest {
// --- Factory helpers ---
private OllamaExtraction extraction(List<String> names, String role, LocalDate from, LocalDate to,
List<String> keywords) {
private NlpExtraction 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);
return new NlpExtraction(names, role, from, to, keywords, raw);
}
private Person person(UUID id, String firstName, String lastName) {
@@ -86,12 +86,13 @@ class NlQueryParserServiceTest {
@Test
void search_resolvesSingleName_asSender() {
Person walter = person(P1, "Walter", "Raddatz");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", "de", PAGE);
verify(nlpClient).parse(eq("Was hat Walter geschrieben?"), eq("de"));
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);
@@ -107,11 +108,11 @@ class NlQueryParserServiceTest {
void search_multiMatchName_populatesAmbiguous_andSkipsSearch() {
Person a = person(UUID.randomUUID(), "Walter", "Braun");
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b)));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
@@ -125,11 +126,11 @@ class NlQueryParserServiceTest {
void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() {
Person a = person(UUID.randomUUID(), "Emma", "Braun");
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b)));
NlSearchResponse resp = service.search("Briefe an Emma", PAGE);
NlSearchResponse resp = service.search("Briefe an Emma", "de", PAGE);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
@@ -140,11 +141,11 @@ class NlQueryParserServiceTest {
@Test
void search_noMatchName_isFoldedIntoText() {
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
service.search("Briefe von Karl", PAGE);
service.search("Briefe von Karl", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -158,11 +159,11 @@ class NlQueryParserServiceTest {
@Test
void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() {
Person walter = person(P1, "Walter", "Raddatz");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
@@ -175,12 +176,12 @@ class NlQueryParserServiceTest {
void search_twoNamesResolve_assignsSenderAndReceiver() {
Person walter = person(P1, "Walter", "Raddatz");
Person emma = person(P2, "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
NlSearchResponse resp = service.search("Briefe von Walter an Emma", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
@@ -197,12 +198,12 @@ class NlQueryParserServiceTest {
Person walter = person(P1, "Walter", "Raddatz");
Person emma1 = person(P2, "Emma", "Braun");
Person emma2 = person(P3, "Emma", "Schmidt");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2)));
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
NlSearchResponse resp = service.search("Briefe von Walter an Emma", "de", PAGE);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
@@ -213,12 +214,12 @@ class NlQueryParserServiceTest {
@Test
void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() {
Person emma = person(P2, "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
service.search("Briefe von Karl an Emma", PAGE);
service.search("Briefe von Karl an Emma", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -233,13 +234,13 @@ class NlQueryParserServiceTest {
Person walter = person(P1, "Walter", "Raddatz");
Person emma = person(P2, "Emma", "Raddatz");
Person heinrich = person(P3, "Heinrich", "Braun");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich)));
service.search("Briefe von Walter an Emma über Heinrich", PAGE);
service.search("Briefe von Walter an Emma über Heinrich", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -252,10 +253,10 @@ class NlQueryParserServiceTest {
@Test
void search_keywords_areJoinedIntoText() {
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter")));
service.search("Dokumente über den Krieg Walter", PAGE);
service.search("Dokumente über den Krieg Walter", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -268,10 +269,10 @@ class NlQueryParserServiceTest {
void search_dateRange_passedIntoSearchFilters() {
LocalDate from = LocalDate.of(1914, 1, 1);
LocalDate to = LocalDate.of(1914, 12, 31);
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", from, to, List.of()));
service.search("Briefe aus dem Jahr 1914", PAGE);
service.search("Briefe aus dem Jahr 1914", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -283,10 +284,10 @@ class NlQueryParserServiceTest {
@Test
void search_nullDates_passedAsNullIntoFilters() {
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
service.search("Hochzeitsbriefe", PAGE);
service.search("Hochzeitsbriefe", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -294,104 +295,75 @@ class NlQueryParserServiceTest {
assertThat(cap.getValue().to()).isNull();
}
// --- 13. Query under 3 chars → VALIDATION_ERROR before Ollama call ---
// --- 13. NLP service returns empty names/keywords → raw query used as keyword fallback ---
@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() {
void search_nlpReturnsEmpty_usesRawQueryAsTextFallback() {
String raw = "Briefe aus dem Krieg";
when(ollamaClient.parse(anyString()))
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of(), raw));
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(new NlpExtraction(List.of(), "any", null, null, List.of(), raw));
service.search(raw, PAGE);
service.search(raw, "de", 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 ---
// --- 14. Null personNames/keywords → no NPE ---
@Test
void search_nullPersonNamesAndKeywords_handledWithoutNpe() {
OllamaExtraction ext = new OllamaExtraction(null, "any", null, null, null, "test query");
when(ollamaClient.parse(anyString())).thenReturn(ext);
NlpExtraction ext = new NlpExtraction(null, "any", null, null, null, "test query");
when(nlpClient.parse(anyString(), anyString())).thenReturn(ext);
NlSearchResponse resp = service.search("test query", PAGE);
NlSearchResponse resp = service.search("test query", "de", PAGE);
assertThat(resp).isNotNull();
verify(documentService).searchDocuments(any(), any(), any(), any());
}
// --- 17. Unrecognized personRole → defaults to any-like behavior (no crash) ---
// --- 15. 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(nlpClient.parse(anyString(), anyString()))
.thenReturn(new NlpExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
NlSearchResponse resp = service.search("Briefe von Walter", "de", 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 ---
// --- 16. NLP service throws SMART_SEARCH_UNAVAILABLE → propagates to caller ---
@Test
void search_ollamaThrowsUnavailable_propagates() {
when(ollamaClient.parse(anyString()))
void search_nlpThrowsUnavailable_propagates() {
when(nlpClient.parse(anyString(), anyString()))
.thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline"));
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", PAGE))
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", "de", PAGE))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
// --- 19. LLM-extracted name > 200 chars → skipped, PersonService never called ---
// --- 17. LLM-extracted name > 200 chars → skipped, PersonService never called ---
@Test
void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() {
String longName = "A".repeat(201);
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(longName), "sender", null, null, List.of()));
service.search("Briefe von sehr langem Namen", PAGE);
service.search("Briefe von sehr langem Namen", "de", PAGE);
verify(personService, never()).resolveByName(anyString());
}
// --- 20. Cap lives in resolveByName (after classification): a pre-capped 10-direct result
// maps straight to ambiguousPersons; the search layer adds no second cap. ---
// --- 18. Cap: 10 direct matches → all shown as ambiguous ---
@Test
void search_tenDirectMatches_allShownAsAmbiguous() {
@@ -399,24 +371,24 @@ class NlQueryParserServiceTest {
for (int i = 0; i < 10; i++) {
ten.add(person(UUID.randomUUID(), "Walter", "Person" + i));
}
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
NlSearchResponse resp = service.search("Briefe von Walter", "de", 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 ---
// --- 19. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty ---
@Test
void search_searchFiltersDefaults_areCorrect() {
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg")));
service.search("Dokumente über den Krieg", PAGE);
service.search("Dokumente über den Krieg", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
@@ -428,16 +400,16 @@ class NlQueryParserServiceTest {
assertThat(f.tagQ()).isNull();
}
// --- 22. personRole=receiver + 1 resolved → receiver UUID set ---
// --- 20. personRole=receiver + 1 resolved → receiver UUID set ---
@Test
void search_personRoleReceiver_singleMatch_setsReceiver() {
Person emma = person(P2, "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
service.search("Briefe an Emma", PAGE);
service.search("Briefe an Emma", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -445,59 +417,59 @@ class NlQueryParserServiceTest {
assertThat(cap.getValue().sender()).isNull();
}
// --- 23. keywordsApplied=true when text is non-blank ---
// --- 21. keywordsApplied=true when text is non-blank ---
@Test
void search_keywordsApplied_trueWhenTextNonBlank() {
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", PAGE);
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", "de", PAGE);
assertThat(resp.interpretation().keywordsApplied()).isTrue();
}
// --- 23a. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
// --- 22. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
@Test
void search_partialOnly_oneCandidate_populatesAmbiguous() {
Person cramer = person(P1, "Clara", "Cramer");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(), List.of(cramer)));
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(1);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
}
// --- 23b. Partial-only, two candidates → ambiguous (multi-item picker) ---
// --- 23. Partial-only, two candidates → ambiguous (multi-item picker) ---
@Test
void search_partialOnly_twoCandidates_populatesAmbiguous() {
Person cramer = person(P1, "Clara", "Cramer");
Person crammond = person(P2, "Clara", "Crammond");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
when(personService.resolveByName("Clara Cram"))
.thenReturn(makeNameMatches(List.of(), List.of(cramer, crammond)));
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
}
// --- 23c. Exactly one direct match → search executes, no picker ---
// --- 24. Exactly one direct match → search executes, no picker ---
@Test
void search_oneDirect_executesSearch() {
Person clara = person(P1, "Clara", "Cram");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(clara)));
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
@@ -519,16 +491,16 @@ class NlQueryParserServiceTest {
private static final UUID T1 = UUID.fromString("00000000-0000-0000-0001-000000000001");
// --- 24. Single keyword resolves to one tag → tag filter applied ---
// --- 25. Single keyword resolves to one tag → tag filter applied ---
@Test
void search_singleKeywordResolvesToTag_appliesTagFilter() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
NlSearchResponse resp = service.search("Briefe über Hochzeit", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -542,17 +514,17 @@ class NlQueryParserServiceTest {
private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002");
// --- 25. Keyword matches multiple tags → all in resolvedTags, OR-union ---
// --- 26. Keyword matches multiple tags → all in resolvedTags, OR-union ---
@Test
void search_keywordMatchesMultipleTags_allIncluded() {
Tag hochzeit1 = tag(T1, "Hochzeit Raddatz");
Tag hochzeit2 = tag(T2, "Hochzeit Braun");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2));
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
NlSearchResponse resp = service.search("Briefe über Hochzeit", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -561,14 +533,14 @@ class NlQueryParserServiceTest {
assertThat(resp.interpretation().resolvedTags()).hasSize(2);
}
// --- 26. Keyword no tag match → stays as FTS text, resolvedTags empty ---
// --- 27. Keyword no tag match → stays as FTS text, resolvedTags empty ---
@Test
void search_keywordNoTagMatch_staysAsFtsText() {
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
NlSearchResponse resp = service.search("Feldpost Briefe", PAGE);
NlSearchResponse resp = service.search("Feldpost Briefe", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -578,16 +550,16 @@ class NlQueryParserServiceTest {
assertThat(resp.interpretation().tagsApplied()).isFalse();
}
// --- 27. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
// --- 28. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
@Test
void search_mixedKeywords_oneResolves_oneStaysAsText() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit und Feldpost", PAGE);
NlSearchResponse resp = service.search("Hochzeit und Feldpost", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -598,18 +570,18 @@ class NlQueryParserServiceTest {
assertThat(resp.interpretation().tagsApplied()).isTrue();
}
// --- 28. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false ---
// --- 29. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false ---
@Test
void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() {
Person walter = person(P1, "Walter", "Raddatz");
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit")));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE);
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", "de", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
@@ -618,7 +590,7 @@ class NlQueryParserServiceTest {
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
}
// --- 29. Cap: keyword matches > 10 tags → capped at 10 ---
// --- 30. Cap: keyword matches > 10 tags → capped at 10 ---
@Test
void search_keywordMatchesMoreThanMaxTags_cappedAtTen() {
@@ -626,11 +598,11 @@ class NlQueryParserServiceTest {
for (int i = 0; i < 11; i++) {
eleven.add(tag(UUID.randomUUID(), "Thema " + i));
}
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Thema")));
when(tagService.findByNameContaining("Thema")).thenReturn(eleven);
NlSearchResponse resp = service.search("Dokumente zum Thema", PAGE);
NlSearchResponse resp = service.search("Dokumente zum Thema", "de", PAGE);
assertThat(resp.interpretation().resolvedTags()).hasSize(10);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
@@ -638,14 +610,14 @@ class NlQueryParserServiceTest {
assertThat(cap.getValue().tags()).hasSize(10);
}
// --- 30. Short keyword (< 3 chars) → skipped, not passed to TagService ---
// --- 31. Short keyword (< 3 chars) → skipped, not passed to TagService ---
@Test
void search_shortKeyword_skippedByTagResolution() {
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg")));
service.search("ab Krieg", PAGE);
service.search("ab Krieg", "de", PAGE);
verify(tagService, never()).findByNameContaining("ab");
verify(tagService).findByNameContaining("Krieg");
@@ -654,17 +626,17 @@ class NlQueryParserServiceTest {
assertThat(cap.getValue().text()).contains("ab");
}
// --- 31. Dedup: same tag matched by two keywords → appears once ---
// --- 32. Dedup: same tag matched by two keywords → appears once ---
@Test
void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit hoch", PAGE);
NlSearchResponse resp = service.search("Hochzeit hoch", "de", PAGE);
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
@@ -672,16 +644,16 @@ class NlQueryParserServiceTest {
assertThat(cap.getValue().tags()).hasSize(1);
}
// --- 32. All keywords resolve → rawQuery fallback suppressed, text=null ---
// --- 33. All keywords resolve → rawQuery fallback suppressed, text=null ---
@Test
void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text"));
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(new NlpExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text"));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit", PAGE);
NlSearchResponse resp = service.search("Hochzeit", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
@@ -689,22 +661,22 @@ class NlQueryParserServiceTest {
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
}
// --- 33. Flag independence: keywordsApplied=false AND tagsApplied=true ---
// --- 34. Flag independence: keywordsApplied=false AND tagsApplied=true ---
@Test
void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit Briefe", PAGE);
NlSearchResponse resp = service.search("Hochzeit Briefe", "de", PAGE);
assertThat(resp.interpretation().keywordsApplied()).isFalse();
assertThat(resp.interpretation().tagsApplied()).isTrue();
}
// --- 34. Color carried through from resolveEffectiveColors ---
// --- 35. Color carried through from resolveEffectiveColors ---
@Test
void search_tagHint_carriesColorSetByResolveEffectiveColors() {
@@ -714,25 +686,25 @@ class NlQueryParserServiceTest {
tags.forEach(t -> t.setColor("sage"));
return null;
}).when(tagService).resolveEffectiveColors(any());
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit", PAGE);
NlSearchResponse resp = service.search("Hochzeit", "de", PAGE);
assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage");
}
// --- 35. Color stays null when resolveEffectiveColors leaves it unset ---
// --- 36. Color stays null when resolveEffectiveColors leaves it unset ---
@Test
void search_tagHint_colorIsNull_whenNoColorResolved() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit", PAGE);
NlSearchResponse resp = service.search("Hochzeit", "de", PAGE);
assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull();
}

View File

@@ -57,11 +57,11 @@ class NlSearchControllerTest {
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns200_withNlSearchResponse() throws Exception {
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(makeResponse());
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter im Krieg\"}"))
.content("{\"query\":\"Briefe von Walter im Krieg\",\"lang\":\"de\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg"))
.andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz"))
@@ -79,11 +79,11 @@ class NlSearchControllerTest {
List.of(), List.of(a, b), null, null,
List.of(), List.of(), "Briefe von Walter", false, false);
NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
when(nlQueryParserService.search(anyString(), any())).thenReturn(resp);
when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(resp);
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray())
.andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun"))
@@ -96,7 +96,7 @@ class NlSearchControllerTest {
void search_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
.andExpect(status().isUnauthorized());
}
@@ -107,7 +107,7 @@ class NlSearchControllerTest {
void search_returns400_whenQueryTooShort() throws Exception {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"ab\"}"))
.content("{\"query\":\"ab\",\"lang\":\"de\"}"))
.andExpect(status().isBadRequest());
}
@@ -119,42 +119,42 @@ class NlSearchControllerTest {
String longQuery = "a".repeat(501);
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"" + longQuery + "\"}"))
.content("{\"query\":\"" + longQuery + "\",\"lang\":\"de\"}"))
.andExpect(status().isBadRequest());
}
// --- 6. Ollama unavailable → 503 ---
// --- 6. NLP service 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"));
void search_returns503_whenNlpServiceUnavailable() throws Exception {
when(nlQueryParserService.search(anyString(), anyString(), any()))
.thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "NLP service offline"));
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE"));
}
// --- 7. 6th request in 1 minute → 429 ---
// --- 7. 6th request in 1 minute → 429 (rate limit = 5/min default) ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns429_onSixthRequestWithinRateLimit() throws Exception {
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
when(nlQueryParserService.search(anyString(), 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\"}"))
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
.andExpect(status().isOk());
}
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED"));
}

View File

@@ -0,0 +1,124 @@
package org.raddatz.familienarchiv.search;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.junit.jupiter.api.AfterEach;
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 static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RestClientNlpClientTest {
private WireMockServer wireMock;
private RestClientNlpClient client;
@BeforeEach
void setUp() {
wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMock.start();
NlpProperties props = new NlpProperties();
props.setBaseUrl("http://localhost:" + wireMock.port());
props.setTimeoutSeconds(5);
props.setHealthCheckTimeoutSeconds(2);
client = new RestClientNlpClient(props);
}
@AfterEach
void tearDown() {
wireMock.stop();
}
private String makeParseResponseJson(String personNamesJson, String personRole,
String dateFrom, String dateTo, String keywordsJson,
String rawQuery) {
return String.format(
"{\"personNames\":%s,\"personRole\":\"%s\",\"dateFrom\":%s,\"dateTo\":%s,\"keywords\":%s,\"rawQuery\":\"%s\"}",
personNamesJson, personRole,
dateFrom == null ? "null" : "\"" + dateFrom + "\"",
dateTo == null ? "null" : "\"" + dateTo + "\"",
keywordsJson, rawQuery
);
}
@Test
void parse_returnsExtraction_whenNlpServiceReturnsValidJson() {
String body = makeParseResponseJson("[\"Walter\"]", "sender", "1914-01-01", "1914-12-31",
"[\"Krieg\"]", "Briefe von Walter im Krieg");
wireMock.stubFor(post(urlEqualTo("/parse"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(body)));
NlpExtraction result = client.parse("Briefe von Walter im Krieg", "de");
assertThat(result.personNames()).containsExactly("Walter");
assertThat(result.personRole()).isEqualTo("sender");
assertThat(result.keywords()).containsExactly("Krieg");
assertThat(result.dateFrom()).isNotNull();
assertThat(result.dateTo()).isNotNull();
}
@Test
void parse_throwsSmartSearchUnavailable_whenNlpServiceReturns500() {
wireMock.stubFor(post(urlEqualTo("/parse"))
.willReturn(aResponse().withStatus(500)));
assertThatThrownBy(() -> client.parse("some query", "de"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
@Test
void parse_throwsSmartSearchUnavailable_whenNlpServiceExceedsTimeout() {
wireMock.stubFor(post(urlEqualTo("/parse"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withFixedDelay(6000)
.withBody("{\"personNames\":[],\"personRole\":\"any\",\"dateFrom\":null,\"dateTo\":null,\"keywords\":[],\"rawQuery\":\"q\"}")));
assertThatThrownBy(() -> client.parse("some query", "de"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
@Test
void isHealthy_returnsTrue_whenPersonsLoadedIsPositive() {
wireMock.stubFor(get(urlEqualTo("/health"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"status\":\"ok\",\"persons_loaded\":42}")));
assertThat(client.isHealthy()).isTrue();
}
@Test
void isHealthy_returnsFalse_whenPersonsLoadedIsZero() {
wireMock.stubFor(get(urlEqualTo("/health"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"status\":\"ok\",\"persons_loaded\":0}")));
assertThat(client.isHealthy()).isFalse();
}
@Test
void isHealthy_returnsFalse_whenNlpServiceIsDown() {
wireMock.stubFor(get(urlEqualTo("/health"))
.willReturn(aResponse().withStatus(503)));
assertThat(client.isHealthy()).isFalse();
}
}