"""Tests for sibilla.search — search clients and page fetcher.""" from unittest import mock from sibilla.search import ( DegoogClient, SearXNGClient, DuckDuckGoClient, PageFetcher, SearchResult, SearchResponse, _clean_html, ) class TestCleanHtml: def test_removes_tags(self): assert _clean_html("
Hello world
") == "Hello world" def test_plain_text_unchanged(self): assert _clean_html("Plain text") == "Plain text" def test_empty_string(self): assert _clean_html("") == "" def test_nested_tags(self): assert _clean_html('Some text
", } ] } with mock.patch.object(client._session, "get") as mock_get: mock_get.return_value.json.return_value = mock_data mock_get.return_value.raise_for_status.return_value = None resp = client.search("q") assert resp.results[0].title == "Bold Title" assert resp.results[0].snippet == "Some text" def test_search_uses_content_fallback_for_snippet(self): """Degoog may return 'content' instead of 'snippet' (OpenWebUI compat).""" client = DegoogClient(base_url="http://degoog.test:4444") mock_data = { "results": [ {"title": "R", "url": "https://x.com", "content": "Content field"} ] } with mock.patch.object(client._session, "get") as mock_get: mock_get.return_value.json.return_value = mock_data mock_get.return_value.raise_for_status.return_value = None resp = client.search("q") assert resp.results[0].snippet == "Content field" def test_search_query_params(self): client = DegoogClient(base_url="http://degoog.test:4444") with mock.patch.object(client._session, "get") as mock_get: mock_get.return_value.json.return_value = {"results": []} mock_get.return_value.raise_for_status.return_value = None client.search("test query", language="en") call_args = mock_get.call_args assert call_args[1]["params"]["q"] == "test query" assert call_args[1]["params"]["lang"] == "en" # ── SearXNGClient tests ─────────────────────────────────────────── class TestSearXNGClient: def test_search_success(self): client = SearXNGClient(base_url="http://searxng.test:8080") mock_data = { "results": [ {"title": "R1", "url": "https://a.com", "content": "S1 content"}, ], "number_of_results": 1, } with mock.patch.object(client._session, "get") as mock_get: mock_get.return_value.json.return_value = mock_data mock_get.return_value.raise_for_status.return_value = None resp = client.search("test", num_results=5) assert len(resp.results) == 1 assert resp.results[0].snippet == "S1 content" assert resp.total_results == 1 def test_search_handles_error(self): client = SearXNGClient(base_url="http://searxng.test:8080") with mock.patch.object(client._session, "get") as mock_get: import requests mock_get.side_effect = requests.RequestException("Timeout") resp = client.search("q") assert resp.error is not None assert "Timeout" in resp.error def test_search_with_engines_param(self): client = SearXNGClient(base_url="http://searxng.test:8080") with mock.patch.object(client._session, "get") as mock_get: mock_get.return_value.json.return_value = {"results": []} mock_get.return_value.raise_for_status.return_value = None client.search("q", engines="google,bing") call_args = mock_get.call_args assert call_args[1]["params"]["engines"] == "google,bing" # ── DuckDuckGoClient tests ──────────────────────────────────────── DUCKDUCKGO_HTML = """Hello world
""" with mock.patch.object(fetcher._session, "get") as mock_get: mock_get.return_value.text = html mock_get.return_value.headers = {"Content-Type": "text/html; charset=utf-8"} mock_get.return_value.raise_for_status.return_value = None content = fetcher.fetch("https://example.com") assert "Hello world" in content assert "alert" not in content # script removed assert "Footer" not in content # footer removed def test_fetch_non_html_returns_empty(self): fetcher = PageFetcher() with mock.patch.object(fetcher._session, "get") as mock_get: mock_get.return_value.headers = {"Content-Type": "application/pdf"} mock_get.return_value.raise_for_status.return_value = None content = fetcher.fetch("https://example.com/doc.pdf") assert content == "" def test_fetch_handles_error(self): fetcher = PageFetcher() with mock.patch.object(fetcher._session, "get") as mock_get: import requests mock_get.side_effect = requests.RequestException("Connection refused") content = fetcher.fetch("https://down.example.com") assert content == "" def test_fetch_truncates_to_max_chars(self): fetcher = PageFetcher(max_chars=50) long_text = "A" * 200 html = f"{long_text}
" with mock.patch.object(fetcher._session, "get") as mock_get: mock_get.return_value.text = html mock_get.return_value.headers = {"Content-Type": "text/html"} mock_get.return_value.raise_for_status.return_value = None content = fetcher.fetch("https://example.com") assert len(content) <= 53 # 50 + "..." truncation marker def test_fetch_results_enriches_objects(self): fetcher = PageFetcher() results = [ SearchResult(title="A", url="https://a.com"), SearchResult(title="B", url="https://b.com"), ] with mock.patch.object(fetcher, "fetch") as mock_fetch: mock_fetch.side_effect = ["Content A", "Content B"] enriched = fetcher.fetch_results(results, max_results=2) assert enriched[0].full_content == "Content A" assert enriched[0].fetched is True assert enriched[1].full_content == "Content B" assert enriched[1].fetched is True def test_fetch_results_respects_max(self): fetcher = PageFetcher() results = [SearchResult(title=f"R{i}", url=f"https://{i}.com") for i in range(5)] with mock.patch.object(fetcher, "fetch") as mock_fetch: mock_fetch.return_value = "Content" fetcher.fetch_results(results, max_results=2) assert mock_fetch.call_count == 2 def test_fetch_results_skips_already_fetched(self): fetcher = PageFetcher() results = [ SearchResult(title="A", url="https://a.com", fetched=True, full_content="Old"), SearchResult(title="B", url="https://b.com"), ] with mock.patch.object(fetcher, "fetch") as mock_fetch: mock_fetch.return_value = "New" fetcher.fetch_results(results, max_results=2) # Only the non-fetched result should be fetched assert mock_fetch.call_count == 1 assert results[0].full_content == "Old" # unchanged