diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index 61c00b6a..76f8e88c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -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 names, String role, LocalDate from, LocalDate to, - List keywords) { + private NlpExtraction extraction(List names, String role, LocalDate from, LocalDate to, + List 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java index c0d30f40..51600aa2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java @@ -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")); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientNlpClientTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientNlpClientTest.java new file mode 100644 index 00000000..0198f6c0 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientNlpClientTest.java @@ -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(); + } +}