refactor(search): delete backend NLP search package

Remove entire backend search domain including:
- NlSearchController, NlQueryParserService, NlpClient implementations
- Rate limiting, properties, DTOs (NlSearchRequest/Response/NlQueryInterpretation)
- All domain logic and tests (5 test files deleted)

Backend compiles successfully post-deletion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-07 18:41:36 +02:00
parent 4232941b99
commit be7ad1d1fa
21 changed files with 768 additions and 1709 deletions

View File

@@ -1,26 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.util.List;
public record NlQueryInterpretation(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<PersonHint> resolvedPersons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<PersonHint> ambiguousPersons,
LocalDate dateFrom,
LocalDate dateTo,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<String> keywords,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<TagHint> resolvedTags,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String rawQuery,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
boolean keywordsApplied,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
boolean tagsApplied
) {
}

View File

@@ -1,211 +0,0 @@
package org.raddatz.familienarchiv.search;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.NameMatches;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class NlQueryParserService {
private static final int MIN_QUERY = 3;
private static final int MAX_QUERY = 500;
private static final int MAX_NAME_LENGTH = 200;
private static final int MIN_TAG_TERM = 3;
private static final int MAX_RESOLVED_TAGS = 10;
private final NlpClient nlpClient;
private final PersonService personService;
private final DocumentService documentService;
private final TagService tagService;
public NlSearchResponse search(String query, String lang, Pageable pageable) {
NlpExtraction ext = nlpClient.parse(query, lang);
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
List<String> keywords = ext.keywords() != null ? ext.keywords() : List.of();
TagResolution tagResolution = resolveTags(keywords);
List<TagHint> resolvedTagHints = tagResolution.hints();
List<String> resolvedTagNames = tagResolution.tagNames();
List<String> remainingKeywords = tagResolution.remaining();
NameResolution resolution = resolveNames(personNames);
if (!resolution.ambiguous().isEmpty()) {
NlQueryInterpretation interpretation = new NlQueryInterpretation(
List.of(), resolution.ambiguous(),
ext.dateFrom(), ext.dateTo(),
keywords, List.of(), ext.rawQuery(), false, false);
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
}
List<PersonHint> resolved = resolution.resolved();
List<String> noMatchFragments = resolution.noMatchFragments();
List<String> extraFragments = resolution.extraFragments();
boolean hadStructuredMatch = !resolvedTagHints.isEmpty() || !resolved.isEmpty();
String text = buildText(remainingKeywords, noMatchFragments, extraFragments, ext.rawQuery(), hadStructuredMatch);
if (resolved.size() == 1 && isAnyRole(ext.personRole())) {
UUID personId = resolved.get(0).id();
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
personId, ext.dateFrom(), ext.dateTo(), pageable);
NlQueryInterpretation interpretation = new NlQueryInterpretation(
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), false, false);
return new NlSearchResponse(docs, interpretation);
}
UUID sender = buildSender(resolved, ext.personRole());
UUID receiver = buildReceiver(resolved, ext.personRole());
boolean tagsApplied = !resolvedTagHints.isEmpty();
TagOperator tagOperator = tagsApplied ? TagOperator.OR : TagOperator.AND;
SearchFilters filters = new SearchFilters(
text.isBlank() ? null : text,
ext.dateFrom(), ext.dateTo(),
sender, receiver,
resolvedTagNames, null,
null, tagOperator, false);
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
boolean keywordsApplied = !text.isBlank();
NlQueryInterpretation interpretation = new NlQueryInterpretation(
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied);
return new NlSearchResponse(docs, interpretation);
}
private NameResolution resolveNames(List<String> personNames) {
List<PersonHint> resolved = new ArrayList<>();
List<PersonHint> ambiguous = new ArrayList<>();
List<String> noMatchFragments = new ArrayList<>();
List<String> extraFragments = new ArrayList<>();
int resolvedIndex = 0;
for (String name : personNames) {
if (name == null || name.length() > MAX_NAME_LENGTH) {
log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length());
continue;
}
NameMatches matches = personService.resolveByName(name);
List<Person> direct = matches.direct();
List<Person> partial = matches.partial();
if (direct.size() == 1) {
Person p = direct.get(0);
resolvedIndex++;
if (resolvedIndex <= 2) {
resolved.add(new PersonHint(p.getId(), p.getDisplayName()));
} else {
extraFragments.add(name);
}
} else if (direct.size() >= 2) {
direct.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
} else if (!partial.isEmpty()) {
partial.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
} else {
noMatchFragments.add(name);
}
}
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
}
private TagResolution resolveTags(List<String> keywords) {
LinkedHashSet<Tag> seen = new LinkedHashSet<>();
List<String> remaining = new ArrayList<>();
for (String kw : keywords) {
if (kw == null || kw.length() < MIN_TAG_TERM) {
remaining.add(kw);
continue;
}
List<Tag> matches = tagService.findByNameContaining(kw);
if (matches.isEmpty()) {
remaining.add(kw);
} else {
seen.addAll(matches);
}
}
if (seen.size() > MAX_RESOLVED_TAGS) {
log.debug("Keyword matched {} tags; capping at {}", seen.size(), MAX_RESOLVED_TAGS);
}
List<Tag> capped = seen.size() > MAX_RESOLVED_TAGS
? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS)
: new ArrayList<>(seen);
// safe: entities are detached here; mutation is for DTO projection only, no dirty-check fires
tagService.resolveEffectiveColors(capped);
List<TagHint> hints = capped.stream()
.map(t -> new TagHint(t.getId(), t.getName(), t.getColor()))
.toList();
List<String> tagNames = capped.stream().map(Tag::getName).toList();
return new TagResolution(hints, tagNames, remaining);
}
private String buildText(List<String> keywords, List<String> noMatchFragments,
List<String> extraFragments, String rawQuery, boolean hadStructuredMatch) {
List<String> parts = new ArrayList<>();
parts.addAll(keywords);
parts.addAll(noMatchFragments);
parts.addAll(extraFragments);
String text = String.join(" ", parts).strip();
if (text.isBlank() && !hadStructuredMatch && rawQuery != null && !rawQuery.isBlank()) {
return rawQuery;
}
return text;
}
private boolean isAnyRole(String role) {
return role == null || "any".equals(role) || (!"sender".equals(role) && !"receiver".equals(role));
}
private UUID buildSender(List<PersonHint> resolved, String role) {
if (resolved.size() >= 2) return resolved.get(0).id();
if (resolved.size() == 1 && "sender".equals(role)) return resolved.get(0).id();
return null;
}
private UUID buildReceiver(List<PersonHint> resolved, String role) {
if (resolved.size() >= 2) return resolved.get(1).id();
if (resolved.size() == 1 && "receiver".equals(role)) return resolved.get(0).id();
return null;
}
private record NameResolution(
List<PersonHint> resolved,
List<PersonHint> ambiguous,
List<String> noMatchFragments,
List<String> extraFragments
) {}
private record TagResolution(
List<TagHint> hints,
List<String> tagNames,
List<String> remaining
) {}
}

View File

@@ -1,28 +0,0 @@
package org.raddatz.familienarchiv.search;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/search/nl")
@RequiredArgsConstructor
public class NlSearchController {
private final NlQueryParserService nlQueryParserService;
private final NlSearchRateLimiter rateLimiter;
@PostMapping
@RequirePermission(Permission.READ_ALL)
public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request,
Pageable pageable,
@AuthenticationPrincipal UserDetails principal) {
rateLimiter.checkAndConsume(principal.getUsername());
return nlQueryParserService.search(request.query(), request.lang(), pageable);
}
}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.search;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties("app.nl-search.rate-limit")
@Data
public class NlSearchRateLimitProperties {
private int maxRequestsPerMinute = 20;
}

View File

@@ -1,46 +0,0 @@
package org.raddatz.familienarchiv.search;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Service
public class NlSearchRateLimiter {
private final LoadingCache<String, Bucket> byUser;
private final int maxRequestsPerMinute;
public NlSearchRateLimiter(NlSearchRateLimitProperties props) {
this.maxRequestsPerMinute = props.getMaxRequestsPerMinute();
this.byUser = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build(key -> newBucket(maxRequestsPerMinute));
}
public void checkAndConsume(String userKey) {
if (!byUser.get(userKey).tryConsume(1)) {
throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED,
"NL search rate limit exceeded for user: " + userKey, 60L);
}
}
void resetForTest() {
byUser.invalidateAll();
}
private static Bucket newBucket(int limit) {
return Bucket.builder()
.addLimit(Bandwidth.builder()
.capacity(limit)
.refillGreedy(limit, Duration.ofMinutes(1))
.build())
.build();
}
}

View File

@@ -1,15 +0,0 @@
package org.raddatz.familienarchiv.search;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record NlSearchRequest(
@NotBlank
@Size(min = 3, max = 500)
String query,
@NotBlank
@Pattern(regexp = "de|en|es")
String lang
) {
}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
public record NlSearchResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
DocumentSearchResult result,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
NlQueryInterpretation interpretation
) {
}

View File

@@ -1,5 +0,0 @@
package org.raddatz.familienarchiv.search;
public interface NlpClient {
NlpExtraction parse(String query, String lang);
}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.search;
import java.time.LocalDate;
import java.util.List;
record NlpExtraction(
List<String> personNames,
String personRole,
LocalDate dateFrom,
LocalDate dateTo,
List<String> keywords,
String rawQuery
) {
}

View File

@@ -1,5 +0,0 @@
package org.raddatz.familienarchiv.search;
public interface NlpHealthClient {
boolean isHealthy();
}

View File

@@ -1,16 +0,0 @@
package org.raddatz.familienarchiv.search;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@ConfigurationProperties("app.nlp")
@Data
@Validated
public class NlpProperties {
@NotBlank
private String baseUrl;
private int timeoutSeconds = 5;
private int healthCheckTimeoutSeconds = 2;
}

View File

@@ -1,13 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record PersonHint(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String displayName
) {
}

View File

@@ -1,145 +0,0 @@
package org.raddatz.familienarchiv.search;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Set;
@Service
@Slf4j
public class RestClientNlpClient implements NlpClient, NlpHealthClient {
private static final Set<String> VALID_ROLES = Set.of("sender", "receiver", "any");
private static final int MAX_NAME_LENGTH = 200;
private static final int MAX_KEYWORD_LENGTH = 100;
private final RestClient parseClient;
private final RestClient healthRestClient;
public RestClientNlpClient(NlpProperties props) {
HttpClient parseHttp = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
JdkClientHttpRequestFactory parseFactory = new JdkClientHttpRequestFactory(parseHttp);
parseFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds()));
this.parseClient = RestClient.builder()
.baseUrl(props.getBaseUrl())
.requestFactory(parseFactory)
.build();
HttpClient healthHttp = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()))
.build();
JdkClientHttpRequestFactory healthFactory = new JdkClientHttpRequestFactory(healthHttp);
healthFactory.setReadTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()));
this.healthRestClient = RestClient.builder()
.baseUrl(props.getBaseUrl())
.requestFactory(healthFactory)
.build();
}
@Override
public NlpExtraction parse(String query, String lang) {
try {
NlpParseRequest request = new NlpParseRequest(query, lang);
NlpParseResponse response = parseClient.post()
.uri("/parse")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(NlpParseResponse.class);
return toExtraction(response, query);
} catch (DomainException e) {
throw e;
} catch (Exception e) {
log.warn("NLP service inference failed: {}", e.getClass().getSimpleName());
throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE,
"NLP service unavailable: " + e.getClass().getSimpleName());
}
}
@Override
public boolean isHealthy() {
try {
NlpHealthResponse resp = healthRestClient.get()
.uri("/health")
.retrieve()
.body(NlpHealthResponse.class);
return resp != null && resp.personsLoaded() > 0;
} catch (Exception e) {
return false;
}
}
private NlpExtraction toExtraction(NlpParseResponse response, String rawQuery) {
if (response == null) {
return fallbackExtraction(rawQuery);
}
List<String> names = response.personNames() == null ? List.of() : response.personNames().stream()
.filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
.toList();
List<String> keywords = response.keywords() == null ? List.of() : response.keywords().stream()
.filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH)
.toList();
String role = sanitiseRole(response.personRole());
LocalDate dateFrom = parseDate(response.dateFrom());
LocalDate dateTo = parseDate(response.dateTo());
return new NlpExtraction(names, role, dateFrom, dateTo, keywords, rawQuery);
}
private NlpExtraction fallbackExtraction(String rawQuery) {
return new NlpExtraction(List.of(), "any", null, null, List.of(), rawQuery);
}
private String sanitiseRole(String role) {
if (role != null && VALID_ROLES.contains(role)) {
return role;
}
log.warn("Unexpected personRole from NLP service: {}", role);
return "any";
}
private LocalDate parseDate(String raw) {
if (raw == null || raw.isBlank()) return null;
try {
return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE);
} catch (DateTimeParseException ignored) {
}
return null;
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record NlpParseRequest(String query, String lang) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record NlpParseResponse(
@JsonProperty("personNames") List<String> personNames,
@JsonProperty("personRole") String personRole,
@JsonProperty("dateFrom") String dateFrom,
@JsonProperty("dateTo") String dateTo,
@JsonProperty("keywords") List<String> keywords
) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record NlpHealthResponse(
@JsonProperty("persons_loaded") int personsLoaded
) {
}
}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record TagHint(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String name,
String color
) {
}

View File

@@ -1,711 +0,0 @@
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.NameMatches;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
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 NlpClient nlpClient;
@Mock PersonService personService;
@Mock DocumentService documentService;
@Mock TagService tagService;
NlQueryParserService service;
static final Pageable PAGE = PageRequest.of(0, 20);
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
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()))
.thenReturn(DocumentSearchResult.of(List.of()));
when(tagService.findByNameContaining(anyString())).thenReturn(List.of());
}
// --- Factory helpers ---
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 NlpExtraction(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 NameMatches makeNameMatches() {
return new NameMatches(List.of(), List.of());
}
private NameMatches makeNameMatches(List<Person> direct) {
return new NameMatches(direct, List.of());
}
private NameMatches makeNameMatches(List<Person> direct, List<Person> partial) {
return new NameMatches(direct, partial);
}
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(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?", "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);
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(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", "de", 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(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", "de", 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(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", "de", 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(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", "de", 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(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", "de", 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(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", "de", 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(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", "de", 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(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", "de", 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(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter")));
service.search("Dokumente über den Krieg Walter", "de", 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(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", from, to, List.of()));
service.search("Briefe aus dem Jahr 1914", "de", 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(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
service.search("Hochzeitsbriefe", "de", 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. NLP service returns empty names/keywords → raw query used as keyword fallback ---
@Test
void search_nlpReturnsEmpty_usesRawQueryAsTextFallback() {
String raw = "Briefe aus dem Krieg";
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(new NlpExtraction(List.of(), "any", null, null, List.of(), raw));
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);
}
// --- 14. Null personNames/keywords → no NPE ---
@Test
void search_nullPersonNamesAndKeywords_handledWithoutNpe() {
NlpExtraction ext = new NlpExtraction(null, "any", null, null, null, "test query");
when(nlpClient.parse(anyString(), anyString())).thenReturn(ext);
NlSearchResponse resp = service.search("test query", "de", PAGE);
assertThat(resp).isNotNull();
verify(documentService).searchDocuments(any(), any(), any(), any());
}
// --- 15. Unrecognized personRole → defaults to any-like behavior (no crash) ---
@Test
void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() {
Person walter = person(P1, "Walter", "Raddatz");
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", "de", PAGE);
assertThat(resp).isNotNull();
}
// --- 16. NLP service throws SMART_SEARCH_UNAVAILABLE → propagates to caller ---
@Test
void search_nlpThrowsUnavailable_propagates() {
when(nlpClient.parse(anyString(), anyString()))
.thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline"));
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", "de", PAGE))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
// --- 17. LLM-extracted name > 200 chars → skipped, PersonService never called ---
@Test
void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() {
String longName = "A".repeat(201);
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(longName), "sender", null, null, List.of()));
service.search("Briefe von sehr langem Namen", "de", PAGE);
verify(personService, never()).resolveByName(anyString());
}
// --- 18. Cap: 10 direct matches → all shown as ambiguous ---
@Test
void search_tenDirectMatches_allShownAsAmbiguous() {
List<Person> ten = new ArrayList<>();
for (int i = 0; i < 10; i++) {
ten.add(person(UUID.randomUUID(), "Walter", "Person" + i));
}
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", "de", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(10);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
}
// --- 19. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty ---
@Test
void search_searchFiltersDefaults_areCorrect() {
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg")));
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));
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();
}
// --- 20. personRole=receiver + 1 resolved → receiver UUID set ---
@Test
void search_personRoleReceiver_singleMatch_setsReceiver() {
Person emma = person(P2, "Emma", "Raddatz");
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", "de", 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();
}
// --- 21. keywordsApplied=true when text is non-blank ---
@Test
void search_keywordsApplied_trueWhenTextNonBlank() {
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", "de", PAGE);
assertThat(resp.interpretation().keywordsApplied()).isTrue();
}
// --- 22. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
@Test
void search_partialOnly_oneCandidate_populatesAmbiguous() {
Person cramer = person(P1, "Clara", "Cramer");
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", "de", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(1);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
}
// --- 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(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", "de", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
}
// --- 24. Exactly one direct match → search executes, no picker ---
@Test
void search_oneDirect_executesSearch() {
Person clara = person(P1, "Clara", "Cram");
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", "de", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
}
// --- Tag resolution helpers ---
private Tag tag(UUID id, String name) {
return Tag.builder().id(id).name(name).build();
}
private Tag tag(UUID id, String name, String color) {
return Tag.builder().id(id).name(name).color(color).build();
}
private TagHint tagHint(UUID id, String name, String color) {
return new TagHint(id, name, color);
}
private static final UUID T1 = UUID.fromString("00000000-0000-0000-0001-000000000001");
// --- 25. Single keyword resolves to one tag → tag filter applied ---
@Test
void search_singleKeywordResolvesToTag_appliesTagFilter() {
Tag hochzeit = tag(T1, "Hochzeit");
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", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
assertThat(resp.interpretation().tagsApplied()).isTrue();
assertThat(cap.getValue().text()).isNull();
}
private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002");
// --- 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(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", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun");
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
assertThat(resp.interpretation().resolvedTags()).hasSize(2);
}
// --- 27. Keyword no tag match → stays as FTS text, resolvedTags empty ---
@Test
void search_keywordNoTagMatch_staysAsFtsText() {
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
NlSearchResponse resp = service.search("Feldpost Briefe", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).contains("Feldpost");
assertThat(cap.getValue().tags()).isEmpty();
assertThat(resp.interpretation().resolvedTags()).isEmpty();
assertThat(resp.interpretation().tagsApplied()).isFalse();
}
// --- 28. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
@Test
void search_mixedKeywords_oneResolves_oneStaysAsText() {
Tag hochzeit = tag(T1, "Hochzeit");
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", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
assertThat(cap.getValue().text()).contains("Feldpost");
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
assertThat(resp.interpretation().tagsApplied()).isTrue();
}
// --- 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(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", "de", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
assertThat(resp.interpretation().tagsApplied()).isFalse();
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
}
// --- 30. Cap: keyword matches > 10 tags → capped at 10 ---
@Test
void search_keywordMatchesMoreThanMaxTags_cappedAtTen() {
List<Tag> eleven = new ArrayList<>();
for (int i = 0; i < 11; i++) {
eleven.add(tag(UUID.randomUUID(), "Thema " + i));
}
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", "de", PAGE);
assertThat(resp.interpretation().resolvedTags()).hasSize(10);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).hasSize(10);
}
// --- 31. Short keyword (< 3 chars) → skipped, not passed to TagService ---
@Test
void search_shortKeyword_skippedByTagResolution() {
when(nlpClient.parse(anyString(), anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg")));
service.search("ab Krieg", "de", PAGE);
verify(tagService, never()).findByNameContaining("ab");
verify(tagService).findByNameContaining("Krieg");
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).contains("ab");
}
// --- 32. Dedup: same tag matched by two keywords → appears once ---
@Test
void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() {
Tag hochzeit = tag(T1, "Hochzeit");
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", "de", PAGE);
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).hasSize(1);
}
// --- 33. All keywords resolve → rawQuery fallback suppressed, text=null ---
@Test
void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() {
Tag hochzeit = tag(T1, "Hochzeit");
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", "de", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).isNull();
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
}
// --- 34. Flag independence: keywordsApplied=false AND tagsApplied=true ---
@Test
void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() {
Tag hochzeit = tag(T1, "Hochzeit");
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", "de", PAGE);
assertThat(resp.interpretation().keywordsApplied()).isFalse();
assertThat(resp.interpretation().tagsApplied()).isTrue();
}
// --- 35. Color carried through from resolveEffectiveColors ---
@Test
void search_tagHint_carriesColorSetByResolveEffectiveColors() {
Tag hochzeit = tag(T1, "Hochzeit");
doAnswer(invocation -> {
Collection<Tag> tags = invocation.getArgument(0);
tags.forEach(t -> t.setColor("sage"));
return null;
}).when(tagService).resolveEffectiveColors(any());
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", "de", PAGE);
assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage");
}
// --- 36. Color stays null when resolveEffectiveColors leaves it unset ---
@Test
void search_tagHint_colorIsNull_whenNoColorResolved() {
Tag hochzeit = tag(T1, "Hochzeit");
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", "de", PAGE);
assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull();
}
}

View File

@@ -1,161 +0,0 @@
package org.raddatz.familienarchiv.search;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(NlSearchController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class,
NlSearchRateLimiter.class, NlSearchRateLimitProperties.class})
class NlSearchControllerTest {
@Autowired MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean NlQueryParserService nlQueryParserService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Autowired NlSearchRateLimiter rateLimiter;
@BeforeEach
void resetRateLimiter() {
rateLimiter.resetForTest();
}
private NlSearchResponse makeResponse() {
PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz");
NlQueryInterpretation interp = new NlQueryInterpretation(
List.of(hint), List.of(), null, null,
List.of("Krieg"), List.of(), "Briefe von Walter im Krieg", true, false);
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
}
// --- 1. Happy path ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns200_withNlSearchResponse() throws Exception {
when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(makeResponse());
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.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"))
.andExpect(jsonPath("$.interpretation.keywordsApplied").value(true));
}
// --- 2. ambiguousPersons in response shape ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns200_withAmbiguousPersons() throws Exception {
PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun");
PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt");
NlQueryInterpretation interp = new NlQueryInterpretation(
List.of(), List.of(a, b), null, null,
List.of(), List.of(), "Briefe von Walter", false, false);
NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
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\",\"lang\":\"de\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray())
.andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun"))
.andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty());
}
// --- 3. Unauthenticated → 401 ---
@Test
void search_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
.andExpect(status().isUnauthorized());
}
// --- 4. Query < 3 chars → 400 ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns400_whenQueryTooShort() throws Exception {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"ab\",\"lang\":\"de\"}"))
.andExpect(status().isBadRequest());
}
// --- 5. Query > 500 chars → 400 ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns400_whenQueryTooLong() throws Exception {
String longQuery = "a".repeat(501);
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"" + longQuery + "\",\"lang\":\"de\"}"))
.andExpect(status().isBadRequest());
}
// --- 6. NLP service unavailable → 503 ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
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\",\"lang\":\"de\"}"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE"));
}
// --- 7. 21st request in 1 minute → 429 (rate limit = 20/min default) ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns429_on21stRequestWithinRateLimit() throws Exception {
when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(makeResponse());
for (int i = 0; i < 20; i++) {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.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\",\"lang\":\"de\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED"));
}
}

View File

@@ -1,62 +0,0 @@
package org.raddatz.familienarchiv.search;
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 org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class NlSearchRateLimiterTest {
private NlSearchRateLimiter rateLimiter;
@BeforeEach
void setUp() {
NlSearchRateLimitProperties props = new NlSearchRateLimitProperties();
props.setMaxRequestsPerMinute(5);
rateLimiter = new NlSearchRateLimiter(props);
}
@Test
void checkAndConsume_allowsRequestsWithinLimit() {
for (int i = 0; i < 5; i++) {
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
.doesNotThrowAnyException();
}
}
@Test
void checkAndConsume_throwsRateLimited_onSixthRequest() {
for (int i = 0; i < 5; i++) {
rateLimiter.checkAndConsume("user@example.com");
}
assertThatThrownBy(() -> rateLimiter.checkAndConsume("user@example.com"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_RATE_LIMITED);
}
@Test
void checkAndConsume_limitsAreIndependentPerUser() {
for (int i = 0; i < 5; i++) {
rateLimiter.checkAndConsume("alice@example.com");
}
assertThatCode(() -> rateLimiter.checkAndConsume("bob@example.com"))
.doesNotThrowAnyException();
}
@Test
void resetForTest_clearsAllBuckets() {
for (int i = 0; i < 5; i++) {
rateLimiter.checkAndConsume("user@example.com");
}
rateLimiter.resetForTest();
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
.doesNotThrowAnyException();
}
}

View File

@@ -1,56 +0,0 @@
package org.raddatz.familienarchiv.search;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class NlSearchTagResolutionIntegrationTest {
@Autowired
private TagRepository tagRepository;
@Test
void findDescendantIdsByName_parentName_includesChildId() {
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
List<UUID> ids = tagRepository.findDescendantIdsByName("Krieg");
assertThat(ids).containsExactlyInAnyOrder(parent.getId(), child.getId());
}
@Test
void findDescendantIdsByName_childName_returnsOnlyChild() {
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
List<UUID> ids = tagRepository.findDescendantIdsByName("Weltkrieg");
assertThat(ids).containsExactly(child.getId());
assertThat(ids).doesNotContain(parent.getId());
}
@Test
void findByNameContainingIgnoreCase_parentSubstring_matchesParentOnly() {
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
List<Tag> found = tagRepository.findByNameContainingIgnoreCase("Krieg");
assertThat(found).extracting(Tag::getName).containsExactlyInAnyOrder("Krieg", "Weltkrieg");
}
}

View File

@@ -1,33 +0,0 @@
package org.raddatz.familienarchiv.search;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class NlpPropertiesTest {
@EnableConfigurationProperties(NlpProperties.class)
static class TestConfig {}
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(TestConfig.class);
@Test
void failsWhenBaseUrlMissing() {
runner.run(context -> assertThat(context).hasFailed());
}
@Test
void bindsBaseUrlAndDefaults() {
runner.withPropertyValues("app.nlp.base-url=http://nlp:8001")
.run(context -> {
assertThat(context).hasNotFailed();
NlpProperties props = context.getBean(NlpProperties.class);
assertThat(props.getBaseUrl()).isEqualTo("http://nlp:8001");
assertThat(props.getTimeoutSeconds()).isEqualTo(5);
assertThat(props.getHealthCheckTimeoutSeconds()).isEqualTo(2);
});
}
}

View File

@@ -1,124 +0,0 @@
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();
}
}