557 lines
21 KiB
Python
557 lines
21 KiB
Python
"""Tests für Volltextsuche und Scope-basierte Filterung im KnowledgeStore.
|
||
|
||
Validiert Task 6.4:
|
||
- SearchResult Dataclass mit entry, relevance_score, partial_results
|
||
- Relevanz-Scoring: title > tags > summary > id
|
||
- Sortierung nach Relevanz (höchster Score zuerst)
|
||
- Scope-Filterung: Nur Ergebnisse aus autorisierten Scopes
|
||
- Keine Offenlegung nicht-autorisierter Artefakte
|
||
- Timeout-Handling: Abbruch nach 5 Sekunden mit Teilergebnis-Warnung
|
||
|
||
Requirements: 3.3, 3.7, 3.9
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
from monorepo.knowledge.index import IndexEntry, YAMLIndex
|
||
from monorepo.knowledge.store import (
|
||
SEARCH_TIMEOUT_SECONDS,
|
||
KnowledgeStore,
|
||
SearchResult,
|
||
_compute_relevance,
|
||
)
|
||
from monorepo.models import ScopeConfig
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def scope_config() -> ScopeConfig:
|
||
"""Beispiel-ScopeConfig."""
|
||
return ScopeConfig(scopes={
|
||
"privat": {"scope": "privat", "paths": ["privat/"]},
|
||
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
|
||
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
|
||
"shared": {"scope": "shared", "paths": ["shared/"]},
|
||
})
|
||
|
||
|
||
@pytest.fixture
|
||
def sample_entries() -> list[IndexEntry]:
|
||
"""Vielfältige Einträge zum Testen der Relevanz-Sortierung."""
|
||
return [
|
||
IndexEntry(
|
||
id="bahn/decisions/api-design",
|
||
title="API-Designprinzipien für Microservices",
|
||
type="decision",
|
||
tags=["api", "microservices", "architecture"],
|
||
scope="bahn",
|
||
summary="REST-API-Konventionen für DB-InfraGO-Dienste",
|
||
path="bahn/decisions/api-design.md",
|
||
content_hash="sha256:abc123",
|
||
links=[],
|
||
),
|
||
IndexEntry(
|
||
id="privat/notes/rest-patterns",
|
||
title="REST-Patterns Notizen",
|
||
type="note",
|
||
tags=["api", "rest", "patterns"],
|
||
scope="privat",
|
||
summary="Sammlung bewährter REST-Patterns",
|
||
path="privat/notes/rest-patterns.md",
|
||
content_hash="sha256:def456",
|
||
links=[],
|
||
),
|
||
IndexEntry(
|
||
id="dhive/reference/cloud-native",
|
||
title="Cloud-Native Architektur Guide",
|
||
type="reference",
|
||
tags=["cloud", "kubernetes", "architecture"],
|
||
scope="dhive",
|
||
summary="Leitfaden für cloud-native Anwendungen bei dhive",
|
||
path="dhive/reference/cloud-native.md",
|
||
content_hash="sha256:ghi789",
|
||
links=[],
|
||
),
|
||
IndexEntry(
|
||
id="shared/patterns/api-versioning",
|
||
title="API Versioning",
|
||
type="pattern",
|
||
tags=["api", "versioning", "best-practices"],
|
||
scope="shared",
|
||
summary="Best Practices für API-Versionierung",
|
||
path="shared/patterns/api-versioning.md",
|
||
content_hash="sha256:jkl012",
|
||
links=[],
|
||
),
|
||
IndexEntry(
|
||
id="bahn/notes/kubernetes-deployment",
|
||
title="Kubernetes Deployment Notizen",
|
||
type="note",
|
||
tags=["kubernetes", "deployment", "infrastructure"],
|
||
scope="bahn",
|
||
summary="Notizen zum Kubernetes-Deployment bei DB-InfraGO",
|
||
path="bahn/notes/kubernetes-deployment.md",
|
||
content_hash="sha256:mno345",
|
||
links=[],
|
||
),
|
||
]
|
||
|
||
|
||
@pytest.fixture
|
||
def store_with_entries(
|
||
tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
|
||
) -> KnowledgeStore:
|
||
"""KnowledgeStore mit vorgeladenen Beispiel-Einträgen."""
|
||
store = KnowledgeStore(tmp_path, scope_config)
|
||
for entry in sample_entries:
|
||
store.index.update_entry(entry)
|
||
return store
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SearchResult Dataclass Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSearchResult:
|
||
"""Tests für die SearchResult Dataclass."""
|
||
|
||
def test_default_values(self) -> None:
|
||
"""SearchResult hat korrekte Standardwerte."""
|
||
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
|
||
result = SearchResult(entry=entry)
|
||
assert result.entry is entry
|
||
assert result.relevance_score == 0.0
|
||
assert result.partial_results is False
|
||
|
||
def test_custom_values(self) -> None:
|
||
"""SearchResult akzeptiert benutzerdefinierte Werte."""
|
||
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
|
||
result = SearchResult(entry=entry, relevance_score=0.85, partial_results=True)
|
||
assert result.relevance_score == 0.85
|
||
assert result.partial_results is True
|
||
|
||
def test_relevance_score_range(self) -> None:
|
||
"""relevance_score liegt zwischen 0.0 und 1.0."""
|
||
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
|
||
# Grenzwerte
|
||
result_zero = SearchResult(entry=entry, relevance_score=0.0)
|
||
result_one = SearchResult(entry=entry, relevance_score=1.0)
|
||
assert result_zero.relevance_score == 0.0
|
||
assert result_one.relevance_score == 1.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Relevanz-Scoring Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestRelevanceScoring:
|
||
"""Tests für die Relevanz-Score-Berechnung."""
|
||
|
||
def test_title_match_highest_score(self) -> None:
|
||
"""Title-Match ergibt höchsten Score (>= 0.8)."""
|
||
entry = IndexEntry(
|
||
id="test/doc",
|
||
title="API Design",
|
||
type="note",
|
||
tags=["documentation"],
|
||
scope="bahn",
|
||
summary="Allgemeine Informationen",
|
||
)
|
||
score = _compute_relevance("api design", entry)
|
||
assert score >= 0.8
|
||
|
||
def test_exact_title_match_maximum_score(self) -> None:
|
||
"""Exakter Title-Match ergibt Score 1.0."""
|
||
entry = IndexEntry(
|
||
id="test/doc",
|
||
title="API Design",
|
||
type="note",
|
||
tags=["misc"],
|
||
scope="bahn",
|
||
summary="Irgendwas",
|
||
)
|
||
score = _compute_relevance("api design", entry)
|
||
assert score == 1.0
|
||
|
||
def test_tag_match_medium_score(self) -> None:
|
||
"""Tag-Match ergibt mittleren Score (0.6 - 0.79)."""
|
||
entry = IndexEntry(
|
||
id="test/doc",
|
||
title="Allgemeines Dokument",
|
||
type="note",
|
||
tags=["kubernetes", "deployment"],
|
||
scope="bahn",
|
||
summary="Nicht relevanter Text",
|
||
)
|
||
score = _compute_relevance("kubernetes", entry)
|
||
assert 0.6 <= score <= 0.79
|
||
|
||
def test_summary_match_lower_score(self) -> None:
|
||
"""Summary-Match ergibt niedrigeren Score (0.4 - 0.59)."""
|
||
entry = IndexEntry(
|
||
id="test/doc",
|
||
title="Allgemeines Dokument",
|
||
type="note",
|
||
tags=["misc"],
|
||
scope="bahn",
|
||
summary="Informationen über Kubernetes-Cluster",
|
||
)
|
||
score = _compute_relevance("kubernetes", entry)
|
||
assert 0.4 <= score <= 0.59
|
||
|
||
def test_id_match_lowest_score(self) -> None:
|
||
"""ID-Match ergibt niedrigsten Score (0.2 - 0.39)."""
|
||
entry = IndexEntry(
|
||
id="bahn/notes/kubernetes-setup",
|
||
title="Allgemeines Dokument",
|
||
type="note",
|
||
tags=["misc"],
|
||
scope="bahn",
|
||
summary="Nicht relevanter Text",
|
||
)
|
||
score = _compute_relevance("kubernetes", entry)
|
||
assert 0.2 <= score <= 0.39
|
||
|
||
def test_no_match_zero_score(self) -> None:
|
||
"""Kein Match ergibt Score 0.0."""
|
||
entry = IndexEntry(
|
||
id="test/doc",
|
||
title="Allgemeines Dokument",
|
||
type="note",
|
||
tags=["misc"],
|
||
scope="bahn",
|
||
summary="Nicht relevanter Text",
|
||
)
|
||
score = _compute_relevance("zyxwv", entry)
|
||
assert score == 0.0
|
||
|
||
def test_title_beats_tags(self) -> None:
|
||
"""Title-Match hat Vorrang vor Tag-Match."""
|
||
entry_title = IndexEntry(
|
||
id="test/a", title="Kubernetes", type="note",
|
||
tags=["misc"], scope="bahn", summary="Anderes",
|
||
)
|
||
entry_tag = IndexEntry(
|
||
id="test/b", title="Anderes Dokument", type="note",
|
||
tags=["kubernetes"], scope="bahn", summary="Anderes",
|
||
)
|
||
score_title = _compute_relevance("kubernetes", entry_title)
|
||
score_tag = _compute_relevance("kubernetes", entry_tag)
|
||
assert score_title > score_tag
|
||
|
||
def test_tags_beat_summary(self) -> None:
|
||
"""Tag-Match hat Vorrang vor Summary-Match."""
|
||
entry_tag = IndexEntry(
|
||
id="test/a", title="Anderes", type="note",
|
||
tags=["kubernetes"], scope="bahn", summary="Anderer Text",
|
||
)
|
||
entry_summary = IndexEntry(
|
||
id="test/b", title="Anderes", type="note",
|
||
tags=["misc"], scope="bahn", summary="Kubernetes ist wichtig",
|
||
)
|
||
score_tag = _compute_relevance("kubernetes", entry_tag)
|
||
score_summary = _compute_relevance("kubernetes", entry_summary)
|
||
assert score_tag > score_summary
|
||
|
||
def test_summary_beats_id(self) -> None:
|
||
"""Summary-Match hat Vorrang vor ID-Match."""
|
||
entry_summary = IndexEntry(
|
||
id="test/other", title="Anderes", type="note",
|
||
tags=["misc"], scope="bahn", summary="Kubernetes Deployment Guide",
|
||
)
|
||
entry_id = IndexEntry(
|
||
id="bahn/kubernetes/setup", title="Anderes", type="note",
|
||
tags=["misc"], scope="bahn", summary="Anderer Text",
|
||
)
|
||
score_summary = _compute_relevance("kubernetes", entry_summary)
|
||
score_id = _compute_relevance("kubernetes", entry_id)
|
||
assert score_summary > score_id
|
||
|
||
def test_case_insensitive(self) -> None:
|
||
"""Relevanz-Berechnung ist case-insensitive bezüglich Entry-Feldern.
|
||
|
||
Die Funktion erwartet query_lower als bereits lowercase Input.
|
||
Die Entry-Felder werden intern lowercase verglichen.
|
||
"""
|
||
entry_upper = IndexEntry(
|
||
id="test/doc", title="API DESIGN", type="note",
|
||
tags=[], scope="bahn", summary="",
|
||
)
|
||
entry_lower = IndexEntry(
|
||
id="test/doc", title="api design", type="note",
|
||
tags=[], scope="bahn", summary="",
|
||
)
|
||
# Gleicher Query (lowercase) gegen verschiedene Entry-Schreibweisen
|
||
score_upper_entry = _compute_relevance("api design", entry_upper)
|
||
score_lower_entry = _compute_relevance("api design", entry_lower)
|
||
assert score_upper_entry == score_lower_entry
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# KnowledgeStore.search() Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestKnowledgeStoreSearch:
|
||
"""Tests für KnowledgeStore.search() mit Relevanz-Sortierung."""
|
||
|
||
def test_returns_search_results(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""search() liefert SearchResult-Objekte."""
|
||
results = store_with_entries.search("api", ["bahn", "privat", "shared"])
|
||
assert len(results) > 0
|
||
for r in results:
|
||
assert isinstance(r, SearchResult)
|
||
assert isinstance(r.entry, IndexEntry)
|
||
assert isinstance(r.relevance_score, float)
|
||
assert isinstance(r.partial_results, bool)
|
||
|
||
def test_sorted_by_relevance_descending(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Ergebnisse sind nach Relevanz absteigend sortiert."""
|
||
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
|
||
scores = [r.relevance_score for r in results]
|
||
assert scores == sorted(scores, reverse=True)
|
||
|
||
def test_title_match_ranked_higher(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Einträge mit Title-Match werden höher gerankt als Tag-Matches."""
|
||
results = store_with_entries.search(
|
||
"api versioning", ["bahn", "privat", "shared", "dhive"]
|
||
)
|
||
# "API Versioning" hat exakten Title-Match
|
||
if results:
|
||
assert results[0].entry.id == "shared/patterns/api-versioning"
|
||
|
||
def test_empty_query_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Leerer Query liefert leere Ergebnisliste."""
|
||
results = store_with_entries.search("", ["bahn", "privat", "shared", "dhive"])
|
||
assert results == []
|
||
|
||
def test_no_match_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Query ohne Treffer liefert leere Liste."""
|
||
results = store_with_entries.search("zyxwvutsrq", ["bahn", "privat", "shared", "dhive"])
|
||
assert results == []
|
||
|
||
def test_relevance_scores_within_range(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Alle Relevanz-Scores liegen zwischen 0.0 und 1.0."""
|
||
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
|
||
for r in results:
|
||
assert 0.0 <= r.relevance_score <= 1.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scope-Filterung Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSearchScopeFiltering:
|
||
"""Tests für Scope-basierte Filterung in der Suche."""
|
||
|
||
def test_only_authorized_scopes_returned(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Nur Ergebnisse aus autorisierten Scopes werden zurückgegeben."""
|
||
results = store_with_entries.search("api", ["bahn"])
|
||
for r in results:
|
||
assert r.entry.scope == "bahn"
|
||
|
||
def test_unauthorized_scope_not_in_results(
|
||
self, store_with_entries: KnowledgeStore
|
||
) -> None:
|
||
"""Artefakte aus nicht-autorisierten Scopes erscheinen nicht."""
|
||
# dhive-Scope nicht autorisiert, aber "cloud" existiert dort
|
||
results = store_with_entries.search("cloud", ["bahn", "privat"])
|
||
for r in results:
|
||
assert r.entry.scope != "dhive"
|
||
|
||
def test_existence_not_revealed(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Existenz nicht-autorisierter Artefakte wird nicht offengelegt.
|
||
|
||
Requirements: 3.9 – unberechtigte Artefakte aus Suchergebnissen
|
||
ausschließen, ohne deren Existenz offenzulegen.
|
||
"""
|
||
# "cloud-native" existiert im dhive-Scope
|
||
results_restricted = store_with_entries.search("cloud-native", ["bahn", "privat"])
|
||
results_all = store_with_entries.search("cloud-native", ["bahn", "privat", "dhive"])
|
||
|
||
# Ohne dhive-Berechtigung: Kein Hinweis auf Existenz
|
||
assert len(results_restricted) == 0
|
||
# Mit dhive-Berechtigung: Ergebnis vorhanden
|
||
assert len(results_all) >= 1
|
||
|
||
def test_empty_scopes_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Leere Scope-Liste liefert keine Ergebnisse."""
|
||
results = store_with_entries.search("api", [])
|
||
assert results == []
|
||
|
||
def test_multiple_scopes(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Suche über mehrere Scopes liefert kombinierte Ergebnisse."""
|
||
results = store_with_entries.search("api", ["bahn", "shared"])
|
||
scopes_found = {r.entry.scope for r in results}
|
||
# Beide Scopes sollten vertreten sein
|
||
assert "bahn" in scopes_found or "shared" in scopes_found
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Timeout-Handling Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSearchTimeout:
|
||
"""Tests für das Timeout-Handling der Suche."""
|
||
|
||
def test_default_timeout_is_5_seconds(self) -> None:
|
||
"""Standard-Timeout ist 5 Sekunden."""
|
||
assert SEARCH_TIMEOUT_SECONDS == 5.0
|
||
|
||
def test_no_timeout_under_normal_conditions(
|
||
self, store_with_entries: KnowledgeStore
|
||
) -> None:
|
||
"""Unter normalen Bedingungen kein Timeout (partial_results=False)."""
|
||
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
|
||
for r in results:
|
||
assert r.partial_results is False
|
||
|
||
def test_timeout_marks_partial_results(
|
||
self, tmp_path: Path, scope_config: ScopeConfig
|
||
) -> None:
|
||
"""Bei Timeout werden alle Ergebnisse als partial markiert."""
|
||
store = KnowledgeStore(tmp_path, scope_config)
|
||
|
||
# Viele Einträge erstellen
|
||
for i in range(100):
|
||
entry = IndexEntry(
|
||
id=f"bahn/test/entry-{i}",
|
||
title=f"Testartikel {i} über API",
|
||
type="note",
|
||
tags=["api", "test"],
|
||
scope="bahn",
|
||
summary=f"Zusammenfassung für Entry {i}",
|
||
path=f"bahn/test/entry-{i}.md",
|
||
content_hash=f"sha256:hash{i}",
|
||
)
|
||
store.index.update_entry(entry)
|
||
|
||
# Timeout auf 0 Sekunden simulieren
|
||
results = store.search("api", ["bahn"], timeout=0.0)
|
||
|
||
# Entweder keine Ergebnisse (sofort Timeout) oder alle partial
|
||
if results:
|
||
for r in results:
|
||
assert r.partial_results is True
|
||
|
||
def test_custom_timeout_parameter(
|
||
self, tmp_path: Path, scope_config: ScopeConfig
|
||
) -> None:
|
||
"""Benutzerdefinierter Timeout wird respektiert."""
|
||
store = KnowledgeStore(tmp_path, scope_config)
|
||
entry = IndexEntry(
|
||
id="bahn/test/entry",
|
||
title="API Test",
|
||
type="note",
|
||
tags=["api"],
|
||
scope="bahn",
|
||
summary="Test",
|
||
path="bahn/test/entry.md",
|
||
content_hash="sha256:test",
|
||
)
|
||
store.index.update_entry(entry)
|
||
|
||
# Großzügiger Timeout: Normale Ergebnisse
|
||
results = store.search("api", ["bahn"], timeout=10.0)
|
||
assert len(results) == 1
|
||
assert results[0].partial_results is False
|
||
|
||
def test_timeout_preserves_found_results(
|
||
self, tmp_path: Path, scope_config: ScopeConfig
|
||
) -> None:
|
||
"""Bei Timeout werden bereits gefundene Teilergebnisse beibehalten."""
|
||
store = KnowledgeStore(tmp_path, scope_config)
|
||
|
||
# Viele Einträge erstellen
|
||
for i in range(1000):
|
||
entry = IndexEntry(
|
||
id=f"bahn/test/entry-{i}",
|
||
title=f"Testartikel {i} über API",
|
||
type="note",
|
||
tags=["api", "test"],
|
||
scope="bahn",
|
||
summary=f"Zusammenfassung für Entry {i}",
|
||
path=f"bahn/test/entry-{i}.md",
|
||
content_hash=f"sha256:hash{i}",
|
||
)
|
||
store.index.update_entry(entry)
|
||
|
||
# Minimaler Timeout: Sollte Teilergebnisse liefern
|
||
# Da die Iteration auf einer modernen Maschine schnell ist,
|
||
# mocken wir time.monotonic für einen kontrollierten Test
|
||
call_count = 0
|
||
real_monotonic = time.monotonic
|
||
|
||
def fake_monotonic() -> float:
|
||
nonlocal call_count
|
||
call_count += 1
|
||
# Nach 5 Aufrufen "Timeout" simulieren
|
||
if call_count > 5:
|
||
return real_monotonic() + 10.0
|
||
return real_monotonic()
|
||
|
||
with patch("monorepo.knowledge.store.time.monotonic", side_effect=fake_monotonic):
|
||
results = store.search("api", ["bahn"], timeout=5.0)
|
||
|
||
# Sollte Teilergebnisse haben (nicht alle 1000)
|
||
if results:
|
||
assert all(r.partial_results is True for r in results)
|
||
assert len(results) < 1000
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSearchIntegration:
|
||
"""Integrationstests für die Volltextsuche."""
|
||
|
||
def test_search_finds_by_title(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Findet Einträge über Titel-Suche."""
|
||
results = store_with_entries.search(
|
||
"Cloud-Native Architektur", ["dhive"]
|
||
)
|
||
assert len(results) >= 1
|
||
assert results[0].entry.id == "dhive/reference/cloud-native"
|
||
assert results[0].relevance_score >= 0.8 # Title match
|
||
|
||
def test_search_finds_by_tag(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Findet Einträge über Tag-Suche."""
|
||
results = store_with_entries.search("deployment", ["bahn"])
|
||
assert len(results) >= 1
|
||
assert any(r.entry.id == "bahn/notes/kubernetes-deployment" for r in results)
|
||
|
||
def test_search_finds_by_summary(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Findet Einträge über Summary-Suche."""
|
||
results = store_with_entries.search("bewährter", ["privat"])
|
||
assert len(results) == 1
|
||
assert results[0].entry.id == "privat/notes/rest-patterns"
|
||
|
||
def test_case_insensitive_search(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Suche ist case-insensitive."""
|
||
results_lower = store_with_entries.search("kubernetes", ["bahn", "dhive"])
|
||
results_upper = store_with_entries.search("KUBERNETES", ["bahn", "dhive"])
|
||
assert len(results_lower) == len(results_upper)
|
||
|
||
def test_search_with_all_scopes(self, store_with_entries: KnowledgeStore) -> None:
|
||
"""Suche über alle Scopes liefert alle Treffer."""
|
||
all_scopes = ["bahn", "privat", "dhive", "shared"]
|
||
results = store_with_entries.search("api", all_scopes)
|
||
# api kommt in mehreren Einträgen vor (title, tags)
|
||
assert len(results) >= 3
|