Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1 @@
"""Tests package."""

View file

@ -0,0 +1,115 @@
"""Pytest fixtures for Web module tests."""
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
from web.api.app import create_app
from web.api.dependencies import init_concurrency_limiter
from web.config import SettingsCache, WebSettings
from web.fetch.client import FetchClient
from web.metasearch.client import SearXNGClient
from web.orchestrator import Orchestrator
from web.schemas.search import SearchResponse, SearchResult
@pytest.fixture(autouse=True)
def clear_settings_cache() -> None:
"""Clear settings cache before each test."""
SettingsCache.clear()
@pytest.fixture
def test_settings() -> WebSettings:
"""Create test settings with mocked values."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
host="127.0.0.1",
port=51100,
api_tokens=None,
)
@pytest.fixture
def mock_search_response() -> SearchResponse:
"""Create a mock search response."""
return SearchResponse(
request_id="test-request-id",
results=[
SearchResult(
query="test query",
url="https://example.com/article",
title="Test Article Title",
snippet="This is a test snippet from the search result.",
rank=1,
site="example.com",
published_at="2024-01-15",
),
SearchResult(
query="test query",
url="https://news.example.com/story",
title="Another Test Result",
snippet="Another snippet for testing purposes.",
rank=2,
site="news.example.com",
published_at=None,
),
],
total_results=2,
execution_time_ms=150.5,
queries_processed=1,
)
@pytest.fixture
def mock_searxng_api_response() -> dict:
"""Create a mock SearXNG API response."""
return {
"results": [
{
"url": "https://example.com/article",
"title": "Test Article Title",
"content": "This is a test snippet from the search result.",
"publishedDate": "2024-01-15",
},
{
"url": "https://news.example.com/story",
"title": "Another Test Result",
"content": "Another snippet for testing purposes.",
"publishedDate": None,
},
]
}
@pytest.fixture
def client_with_mock(
test_settings: WebSettings, mock_search_response: SearchResponse
) -> SearXNGClient:
"""Create a SearXNGClient with mocked search method."""
SettingsCache.set(test_settings)
client = SearXNGClient(settings=test_settings)
client.search = AsyncMock(return_value=mock_search_response)
return client
@pytest.fixture
def app_client(test_settings: WebSettings) -> TestClient:
"""Create a FastAPI TestClient with test settings."""
SettingsCache.set(test_settings)
init_concurrency_limiter(test_settings.max_concurrent_requests)
app = create_app()
app.state.settings = test_settings
app.state.search_client = SearXNGClient(settings=test_settings)
app.state.fetch_client = FetchClient(settings=test_settings)
app.state.orchestrator = Orchestrator(settings=test_settings)
return TestClient(app)

View file

@ -0,0 +1,391 @@
"""Tests for API endpoints."""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from web.api.app import create_app
from web.api.dependencies import init_concurrency_limiter
from web.config import SettingsCache, WebSettings
from web.exceptions import (
ProviderError,
RateLimitError,
WebConnectionError,
WebTimeoutError,
)
from web.fetch.client import FetchClient
from web.metasearch.client import SearXNGClient
from web.orchestrator import Orchestrator
from web.schemas.search import SearchResponse, SearchResult
from web.search.paid import PaidSearchClient
@pytest.fixture
def mock_response() -> SearchResponse:
"""Create a mock search response."""
return SearchResponse(
request_id="test-id",
results=[
SearchResult(
query="test",
url="https://example.com",
title="Test",
snippet="Test snippet",
rank=1,
site="example.com",
published_at=None,
)
],
total_results=1,
execution_time_ms=100.0,
queries_processed=1,
)
@pytest.fixture
def test_client(test_settings: WebSettings) -> TestClient:
"""Create test client with mocked settings."""
SettingsCache.set(test_settings)
init_concurrency_limiter(test_settings.max_concurrent_requests)
app = create_app()
app.state.settings = test_settings
app.state.search_client = SearXNGClient(settings=test_settings)
app.state.search_client_free = SearXNGClient(settings=test_settings)
app.state.search_client_premium = PaidSearchClient(settings=test_settings)
app.state.fetch_client = FetchClient(settings=test_settings)
app.state.orchestrator_free = Orchestrator(settings=test_settings)
app.state.orchestrator_premium = Orchestrator(
settings=test_settings, llm_provider="openrouter"
)
return TestClient(app)
class TestHealthEndpoints:
"""Tests for health check endpoints."""
def test_ready_endpoint(self, test_client: TestClient) -> None:
"""Test /ready endpoint."""
response = test_client.get("/ready")
assert response.status_code == 200
assert response.json() == {"ready": True}
def test_health_endpoint(self, test_client: TestClient) -> None:
"""Test /health endpoint."""
with patch.object(
SearXNGClient, "health_check", new_callable=AsyncMock
) as mock_health:
mock_health.return_value = True
response = test_client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert len(data["providers"]) == 1
assert data["providers"][0]["name"] == "searxng"
class TestSearchEndpoint:
"""Tests for /v1/search endpoint."""
def test_search_success(
self, test_client: TestClient, mock_response: SearchResponse
) -> None:
"""Test successful search."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = mock_response
response = test_client.post(
"/v1/search",
json={"queries": ["test query"], "max_results": 5},
)
assert response.status_code == 200
data = response.json()
assert data["total_results"] == 1
assert len(data["results"]) == 1
assert data["results"][0]["url"] == "https://example.com"
def test_search_validation_empty_query(self, test_client: TestClient) -> None:
"""Test that empty query is rejected."""
response = test_client.post(
"/v1/search",
json={"queries": [""], "max_results": 5},
)
assert response.status_code == 422
def test_search_validation_too_many_queries(self, test_client: TestClient) -> None:
"""Test that too many queries are rejected."""
response = test_client.post(
"/v1/search",
json={"queries": [f"query{i}" for i in range(15)], "max_results": 5},
)
assert response.status_code == 422
def test_search_with_site_allowlist(
self, test_client: TestClient, mock_response: SearchResponse
) -> None:
"""Test search with site allowlist."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = mock_response
response = test_client.post(
"/v1/search",
json={
"queries": ["test"],
"site_allowlist": ["example.com", "test.com"],
},
)
assert response.status_code == 200
def test_request_id_header(
self, test_client: TestClient, mock_response: SearchResponse
) -> None:
"""Test that X-Request-ID header is returned."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = mock_response
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"X-Request-ID": "custom-request-id"},
)
assert response.status_code == 200
assert "X-Request-ID" in response.headers
assert response.headers["X-Request-ID"] == "custom-request-id"
class TestFetchEndpoint:
"""Tests for /v1/fetch endpoint."""
def test_fetch_rejects_private_urls(self, test_client: TestClient) -> None:
"""Test that private/localhost URLs are rejected (SSRF protection)."""
response = test_client.post(
"/v1/fetch",
json={"urls": ["http://localhost/secret"]},
)
assert response.status_code == 422
def test_fetch_rejects_non_http_schemes(self, test_client: TestClient) -> None:
"""Test that non-HTTP(S) schemes are rejected."""
response = test_client.post(
"/v1/fetch",
json={"urls": ["ftp://example.com/file"]},
)
assert response.status_code == 422
def test_fetch_validation_empty_urls(self, test_client: TestClient) -> None:
"""Test that empty URL list is rejected."""
response = test_client.post(
"/v1/fetch",
json={"urls": []},
)
assert response.status_code == 422
class TestInfoEndpoint:
"""Tests for /v1/info endpoint."""
def test_info_returns_resource(self, test_client: TestClient) -> None:
"""Test that /v1/info returns resource information."""
response = test_client.get("/v1/info")
assert response.status_code == 200
data = response.json()
assert "resource" in data
assert data["resource"]["slug"] == "web-factcheck"
def test_info_returns_functions(self, test_client: TestClient) -> None:
"""Test that /v1/info returns function definitions."""
response = test_client.get("/v1/info")
assert response.status_code == 200
data = response.json()
assert "functions" in data
assert len(data["functions"]) >= 2
slugs = [f["slug"] for f in data["functions"]]
assert "web-gather-evidence" in slugs
assert "web-search" in slugs
assert "web-fetch" in slugs
class TestSearchErrorResponses:
"""Tests for error response codes on /v1/search."""
def test_search_returns_429_on_rate_limit(self, test_client: TestClient) -> None:
"""Test that RateLimitError returns 429."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = RateLimitError(
"Rate limit exceeded", retry_after=5.0
)
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 429
assert "Retry-After" in response.headers
def test_search_returns_502_on_connection_error(
self, test_client: TestClient
) -> None:
"""Test that WebConnectionError returns 502."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = WebConnectionError("searxng", "refused")
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 502
data = response.json()
assert data["detail"]["error"] == "connection_error"
def test_search_returns_502_on_provider_error(
self, test_client: TestClient
) -> None:
"""Test that ProviderError returns 502."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = ProviderError("searxng", "Server error")
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 502
data = response.json()
assert data["detail"]["error"] == "provider_error"
def test_search_returns_504_on_timeout(self, test_client: TestClient) -> None:
"""Test that WebTimeoutError returns 504."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = WebTimeoutError("Timeout", timeout=30.0)
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 504
data = response.json()
assert data["detail"]["error"] == "timeout"
class TestAuthFlow:
"""Tests for Bearer token authentication."""
@pytest.fixture
def auth_settings(self) -> WebSettings:
"""Create settings with authentication enabled."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
api_tokens="valid-token-123,valid-token-456",
)
@pytest.fixture
def auth_client(self, auth_settings: WebSettings) -> TestClient:
"""Create test client with auth enabled."""
SettingsCache.set(auth_settings)
init_concurrency_limiter(auth_settings.max_concurrent_requests)
app = create_app()
app.state.settings = auth_settings
app.state.search_client = SearXNGClient(settings=auth_settings)
app.state.search_client_free = SearXNGClient(settings=auth_settings)
app.state.search_client_premium = PaidSearchClient(settings=auth_settings)
app.state.fetch_client = FetchClient(settings=auth_settings)
app.state.orchestrator_free = Orchestrator(settings=auth_settings)
app.state.orchestrator_premium = Orchestrator(
settings=auth_settings, llm_provider="openrouter"
)
return TestClient(app)
def test_auth_required_returns_401_without_token(
self, auth_client: TestClient
) -> None:
"""Test that missing auth header returns 401."""
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 401
def test_auth_required_returns_401_with_invalid_token(
self, auth_client: TestClient
) -> None:
"""Test that invalid token returns 401."""
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
def test_auth_succeeds_with_valid_token(self, auth_client: TestClient) -> None:
"""Test that valid token allows access."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = SearchResponse(
request_id="test",
results=[],
total_results=0,
execution_time_ms=10.0,
queries_processed=1,
)
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"Authorization": "Bearer valid-token-123"},
)
assert response.status_code == 200
def test_auth_invalid_format_returns_401(self, auth_client: TestClient) -> None:
"""Test that non-Bearer auth format returns 401."""
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"Authorization": "Basic dXNlcjpwYXNz"},
)
assert response.status_code == 401
def test_health_endpoints_skip_auth(self, auth_client: TestClient) -> None:
"""Test that health endpoints don't require auth."""
response = auth_client.get("/ready")
assert response.status_code == 200

View file

@ -0,0 +1,198 @@
"""Unit tests for BrowseClient with mocked Playwright."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from web.config import WebSettings
@pytest.fixture
def settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
)
class TestBrowseClientImport:
"""Tests for BrowseClient import handling."""
def test_raises_import_error_without_playwright(
self, settings: WebSettings
) -> None:
"""Test that BrowseClient raises ImportError without playwright."""
with patch("web.browse.client.HAS_PLAYWRIGHT", False):
from web.browse.client import BrowseClient
with pytest.raises(ImportError, match="playwright"):
BrowseClient(settings)
class TestBrowseClientBrowse:
"""Tests for BrowseClient.browse with mocked browser."""
def _make_mock_page(self) -> MagicMock:
"""Create a fresh mock Playwright page."""
page = AsyncMock()
page.goto = AsyncMock()
page.title = AsyncMock(return_value="Test Page Title")
page.url = "https://example.com/page"
page.content = AsyncMock(return_value="<html><body>Content</body></html>")
page.evaluate = AsyncMock(
side_effect=[
None, # remove unwanted elements
"Main content of the page with sufficient text for testing.",
"https://example.com/canonical", # canonical URL
"2024-01-15T00:00:00Z", # publication date
]
)
page.screenshot = AsyncMock(return_value=b"\x89PNG\r\n")
return page
def _make_mock_context(self, mock_page: MagicMock | None = None) -> MagicMock:
"""Create a mock browser context."""
if mock_page is None:
mock_page = self._make_mock_page()
context = AsyncMock()
context.new_page = AsyncMock(return_value=mock_page)
context.close = AsyncMock()
return context
def _make_mock_browser(self, mock_context: MagicMock | None = None) -> MagicMock:
"""Create a mock browser that creates fresh contexts each time."""
if mock_context is not None:
browser = MagicMock()
browser.is_connected = MagicMock(return_value=True)
browser.new_context = AsyncMock(return_value=mock_context)
browser.close = AsyncMock()
return browser
# Create a browser that returns fresh page mocks each time
browser = MagicMock()
browser.is_connected = MagicMock(return_value=True)
async def _new_context(**kwargs):
return self._make_mock_context()
browser.new_context = AsyncMock(side_effect=_new_context)
browser.close = AsyncMock()
return browser
@pytest.mark.asyncio
async def test_browse_single_page(self, settings: WebSettings) -> None:
"""Test browsing a single page returns content."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
from web.schemas.browse import BrowseRequest
mock_browser = self._make_mock_browser()
client = BrowseClient(settings, browser=mock_browser)
request = BrowseRequest(urls=["https://example.com/page"])
response = await client.browse(request)
assert response.total_browsed == 1
assert response.pages[0].title == "Test Page Title"
assert "Main content" in response.pages[0].text
@pytest.mark.asyncio
async def test_browse_handles_timeout(self, settings: WebSettings) -> None:
"""Test that Playwright timeout is properly wrapped."""
class MockPlaywrightTimeout(Exception):
pass
with (
patch("web.browse.client.HAS_PLAYWRIGHT", True),
patch(
"web.browse.client.PlaywrightTimeout",
MockPlaywrightTimeout,
create=True,
),
):
from web.browse.client import BrowseClient
from web.schemas.browse import BrowseRequest
mock_page = AsyncMock()
mock_page.goto = AsyncMock(side_effect=MockPlaywrightTimeout("Timeout"))
mock_context = self._make_mock_context(mock_page)
mock_browser = self._make_mock_browser(mock_context)
client = BrowseClient(settings, browser=mock_browser)
request = BrowseRequest(urls=["https://slow.example.com"])
response = await client.browse(request)
assert response.total_failed == 1
assert response.total_browsed == 0
@pytest.mark.asyncio
async def test_browse_multiple_urls(self, settings: WebSettings) -> None:
"""Test browsing multiple URLs concurrently."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
from web.schemas.browse import BrowseRequest
# Use browser that creates fresh contexts/pages
mock_browser = self._make_mock_browser()
client = BrowseClient(settings, browser=mock_browser)
request = BrowseRequest(
urls=["https://example.com/a", "https://example.com/b"],
parallel_browses=2,
)
response = await client.browse(request)
assert response.total_browsed == 2
@pytest.mark.asyncio
async def test_close_only_closes_owned_browser(self, settings: WebSettings) -> None:
"""Test that close() only closes browser if client owns it."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
mock_browser = self._make_mock_browser()
client = BrowseClient(settings, browser=mock_browser)
await client.close()
mock_browser.close.assert_not_called()
@pytest.mark.asyncio
async def test_concurrent_get_browser_single_instance(
self, settings: WebSettings
) -> None:
"""Test that concurrent _get_browser calls produce a single browser."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
mock_browser = self._make_mock_browser()
launch_count = 0
async def mock_launch(**kwargs):
nonlocal launch_count
launch_count += 1
await asyncio.sleep(0.05) # Simulate async work
return mock_browser
mock_pw = AsyncMock()
mock_pw.chromium.launch = mock_launch
mock_pw_ctx = AsyncMock()
mock_pw_ctx.start = AsyncMock(return_value=mock_pw)
client = BrowseClient(settings)
with patch("web.browse.client.async_playwright", return_value=mock_pw_ctx):
# Launch many concurrent _get_browser calls
results = await asyncio.gather(
*[client._get_browser() for _ in range(5)]
)
# Lock ensures only one browser was launched
assert launch_count == 1
# All results should be the same instance
assert all(r is results[0] for r in results)

View file

@ -0,0 +1,100 @@
"""Tests for configuration module."""
import pytest
from pydantic import ValidationError
from web.config import SettingsCache, WebSettings
# Shared test defaults for required fields
_TEST_DEFAULTS = {
"searxng_base_url": "http://localhost:55100",
"llm_base_url": "http://localhost:14011",
"external_url": "http://localhost:51100",
}
class TestWebSettings:
"""Tests for WebSettings."""
def test_required_searxng_base_url(self) -> None:
"""Test that searxng_base_url is required."""
with pytest.raises(ValidationError) as exc_info:
WebSettings(
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
)
errors = exc_info.value.errors()
assert any(e["loc"] == ("searxng_base_url",) for e in errors)
def test_required_llm_base_url(self) -> None:
"""Test that llm_base_url is required (no default)."""
with pytest.raises(ValidationError) as exc_info:
WebSettings(
searxng_base_url="http://localhost:55100",
external_url="http://localhost:51100",
)
errors = exc_info.value.errors()
assert any(e["loc"] == ("llm_base_url",) for e in errors)
def test_required_external_url(self) -> None:
"""Test that external_url is required."""
with pytest.raises(ValidationError) as exc_info:
WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
)
errors = exc_info.value.errors()
assert any(e["loc"] == ("external_url",) for e in errors)
def test_valid_settings(self) -> None:
"""Test valid settings creation."""
settings = WebSettings(**_TEST_DEFAULTS)
assert settings.searxng_base_url == "http://localhost:55100"
assert settings.port == 51100
assert settings.host == "0.0.0.0"
assert settings.search_default_max_results == 10
assert settings.connect_timeout == 5.0
def test_api_tokens_parsing(self) -> None:
"""Test comma-separated API tokens parsing."""
settings = WebSettings(
**_TEST_DEFAULTS,
api_tokens="token1,token2,token3",
)
assert settings.api_tokens == frozenset(["token1", "token2", "token3"])
assert settings.auth_enabled is True
def test_auth_disabled_by_default(self) -> None:
"""Test that auth is disabled when no tokens set."""
settings = WebSettings(**_TEST_DEFAULTS)
assert settings.api_tokens is None
assert settings.auth_enabled is False
class TestSettingsCache:
"""Tests for SettingsCache."""
def test_cache_returns_same_instance(self) -> None:
"""Test that get() returns the same instance."""
SettingsCache.set(WebSettings(**_TEST_DEFAULTS))
settings1 = SettingsCache.get()
settings2 = SettingsCache.get()
assert settings1 is settings2
def test_cache_clear(self) -> None:
"""Test that clear() resets the cache."""
SettingsCache.set(WebSettings(**_TEST_DEFAULTS))
SettingsCache.clear()
# This should raise because no .env and no required config vars
with pytest.raises(ValidationError):
SettingsCache.get()

View file

@ -0,0 +1,313 @@
"""Tests for evidence packer - deduplication, credibility scoring, circuit breaker."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from web.config import WebSettings
from web.evidence.packer import EvidencePacker
from web.schemas.common import PageContent
from web.schemas.evidence import EvidencePackRequest
@pytest.fixture
def test_settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
)
@pytest.fixture
def packer(test_settings: WebSettings) -> EvidencePacker:
"""Create an EvidencePacker instance."""
return EvidencePacker(test_settings)
class TestDeduplication:
"""Tests for content deduplication."""
def test_identical_texts_high_similarity(self, packer: EvidencePacker) -> None:
"""Test that identical texts have very high similarity."""
text = "This is a test document about climate change effects."
score = packer._calculate_similarity(text, text)
assert score >= 0.95
def test_different_texts_low_similarity(self, packer: EvidencePacker) -> None:
"""Test that very different texts have low similarity."""
text1 = "Climate change is affecting global temperatures."
text2 = "The stock market rallied yesterday on positive earnings."
score = packer._calculate_similarity(text1, text2)
assert score < 0.5
def test_empty_texts_zero_similarity(self, packer: EvidencePacker) -> None:
"""Test that empty texts return zero similarity."""
assert packer._calculate_similarity("", "") == 0.0
assert packer._calculate_similarity("text", "") == 0.0
assert packer._calculate_similarity("", "text") == 0.0
def test_length_ratio_prefilter(self, packer: EvidencePacker) -> None:
"""Test that very different length texts get low similarity via pre-filter."""
short = "Hello"
long = "This is a very long text " * 100
score = packer._calculate_similarity(short, long)
assert score < 0.5
class TestCredibilityScoring:
"""Tests for credibility scoring."""
def test_high_credibility_domain(self, packer: EvidencePacker) -> None:
"""Test that known credible domains get high scores."""
score = packer._score_credibility_simple("https://reuters.com/article/123")
assert score == 0.9
def test_high_credibility_with_www(self, packer: EvidencePacker) -> None:
"""Test that www prefix is stripped."""
score = packer._score_credibility_simple("https://www.bbc.com/news/article")
assert score == 0.9
def test_medium_credibility_domain(self, packer: EvidencePacker) -> None:
"""Test that medium-credibility domains get appropriate scores."""
score = packer._score_credibility_simple("https://cnn.com/article")
assert score == 0.7
def test_unknown_domain_default_score(self, packer: EvidencePacker) -> None:
"""Test that unknown domains get default score."""
score = packer._score_credibility_simple("https://random-blog.com/post")
assert score == 0.5
def test_subdomain_does_not_match(self, packer: EvidencePacker) -> None:
"""Test that 'notreuters.com' does not match 'reuters.com'."""
score = packer._score_credibility_simple("https://notreuters.com/article")
assert score == 0.5
def test_subdomain_matches(self, packer: EvidencePacker) -> None:
"""Test that 'news.bbc.co.uk' matches 'bbc.co.uk'."""
score = packer._score_credibility_simple("https://news.bbc.co.uk/article")
assert score == 0.9
class TestThinkingTagStripping:
"""Tests for LLM thinking tag removal."""
def test_strips_complete_think_tags(self, packer: EvidencePacker) -> None:
"""Test stripping complete <think>...</think> blocks."""
text = "<think>Let me analyze this...</think>The answer is 42."
result = packer._strip_thinking_tags(text)
assert result == "The answer is 42."
def test_strips_truncated_think_tags(self, packer: EvidencePacker) -> None:
"""Test stripping truncated <think> without closing tag."""
text = "<think>Still thinking about this"
result = packer._strip_thinking_tags(text)
assert result == ""
def test_preserves_text_without_tags(self, packer: EvidencePacker) -> None:
"""Test that text without think tags is preserved."""
text = "No thinking tags here."
result = packer._strip_thinking_tags(text)
assert result == "No thinking tags here."
class TestLLMCircuitBreaker:
"""Tests for LLM availability probe / circuit breaker."""
@pytest.mark.asyncio
async def test_probe_marks_unavailable_on_connection_error(
self, packer: EvidencePacker
) -> None:
"""Test that a connection error marks LLM as unavailable."""
with patch("web.evidence.packer.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.ConnectError("refused")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_cls.return_value = mock_client
result = await packer._is_llm_available()
assert result is False
assert packer._llm_available is False
@pytest.mark.asyncio
async def test_probe_marks_available_on_success(
self, packer: EvidencePacker
) -> None:
"""Test that a successful response marks LLM as available."""
with patch("web.evidence.packer.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_resp = AsyncMock()
mock_resp.raise_for_status = lambda: None
mock_client.get.return_value = mock_resp
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_cls.return_value = mock_client
result = await packer._is_llm_available()
assert result is True
assert packer._llm_available is True
@pytest.mark.asyncio
async def test_pack_disables_llm_when_unreachable(
self, packer: EvidencePacker
) -> None:
"""Test that pack() disables LLM features when probe fails."""
page = PageContent(
url="https://example.com/article",
title="Test",
text="Enough content to process for evidence packing test." * 5,
text_hash="abc123",
extraction_method="http",
retrieved_at="2025-01-01T00:00:00Z",
extraction_time_ms=100.0,
)
request = EvidencePackRequest(
pages=[page],
claim="Test claim for circuit breaker verification",
score_relevance=True,
extract_snippets=False,
)
# Make probe fail
with patch.object(
packer, "_is_llm_available", new_callable=AsyncMock, return_value=False
):
response = await packer.pack(request)
# Should succeed without LLM calls (no errors)
assert len(response.evidence) == 1
# No LLM tokens used since LLM was disabled
assert response.stats.tokens_used == 0
@pytest.mark.asyncio
async def test_negative_cache_prevents_repeated_probes(
self, packer: EvidencePacker
) -> None:
"""Test that negative cache prevents repeated probe calls."""
with patch("web.evidence.packer.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.ConnectError("refused")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_cls.return_value = mock_client
# First call: probes and caches negative result
result1 = await packer._is_llm_available()
assert result1 is False
# Second call: should use cached result (no new probe)
mock_cls.reset_mock()
result2 = await packer._is_llm_available()
assert result2 is False
mock_cls.assert_not_called()
class TestSummarize:
"""Tests for LLM summarization feature."""
def test_parse_batch_summary_extracts_correct_summary(
self, packer: EvidencePacker
) -> None:
"""Test parsing individual summaries from batch response."""
response = (
"SUMMARY 1: This is the first summary about economics.\n"
"SUMMARY 2: This is the second summary about politics."
)
result1 = packer._parse_batch_summary(response, 1, 800)
result2 = packer._parse_batch_summary(response, 2, 800)
assert result1 is not None
assert "first summary" in result1
assert result2 is not None
assert "second summary" in result2
def test_parse_batch_summary_returns_none_for_missing(
self, packer: EvidencePacker
) -> None:
"""Test that missing summary number returns None."""
response = "SUMMARY 1: Only one summary here."
result = packer._parse_batch_summary(response, 3, 800)
assert result is None
def test_parse_batch_summary_respects_max_length(
self, packer: EvidencePacker
) -> None:
"""Test that summaries are truncated to max_length."""
long_text = "A" * 1000
response = f"SUMMARY 1: {long_text}"
result = packer._parse_batch_summary(response, 1, 200)
assert result is not None
assert len(result) <= 200
@pytest.mark.asyncio
async def test_pack_disables_summarize_when_llm_unreachable(
self, packer: EvidencePacker
) -> None:
"""Test that pack() disables summarize when LLM probe fails."""
page = PageContent(
url="https://example.com/article",
title="Test",
text="Content about economic growth in Romania." * 10,
text_hash="summ123",
extraction_method="http",
retrieved_at="2025-01-01T00:00:00Z",
extraction_time_ms=100.0,
)
request = EvidencePackRequest(
pages=[page],
claim="Romania had the highest economic growth in the EU in 2024",
summarize=True,
)
with patch.object(
packer, "_is_llm_available", new_callable=AsyncMock, return_value=False
):
response = await packer.pack(request)
assert len(response.evidence) == 1
# Summary should be None since LLM was disabled
assert response.evidence[0].summary is None
assert response.stats.tokens_used == 0
@pytest.mark.asyncio
async def test_summarize_produces_summary_field(
self, packer: EvidencePacker
) -> None:
"""Test that summarize=True produces a summary field via LLM."""
page = PageContent(
url="https://example.com/article",
title="Test Article",
text="Romania's GDP grew by 4.1% in 2024, the highest in the EU." * 5,
text_hash="summ456",
extraction_method="http",
retrieved_at="2025-01-01T00:00:00Z",
extraction_time_ms=100.0,
)
request = EvidencePackRequest(
pages=[page],
claim="Romania had the highest economic growth in the EU in 2024",
summarize=True,
)
mock_summary = "Romania achieved 4.1% GDP growth in 2024, leading the EU."
with (
patch.object(
packer, "_is_llm_available", new_callable=AsyncMock, return_value=True
),
patch.object(
packer,
"_summarize_single",
new_callable=AsyncMock,
return_value=(mock_summary, 150),
),
):
response = await packer.pack(request)
assert len(response.evidence) == 1
assert response.evidence[0].summary == mock_summary
assert response.stats.tokens_used == 150

View file

@ -0,0 +1,185 @@
"""Unit tests for FetchClient with mocked httpx."""
import contextlib
from unittest.mock import AsyncMock, patch
import pytest
from web.config import WebSettings
from web.fetch.client import FetchClient
from web.schemas.fetch import FetchRequest
@pytest.fixture
def settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
fetch_min_text_length=200,
)
@pytest.fixture
def client(settings: WebSettings) -> FetchClient:
"""Create a FetchClient."""
return FetchClient(settings)
def _make_html(text: str, title: str = "Test Page") -> str:
"""Create a simple HTML page."""
return f"<html><head><title>{title}</title></head><body><main><p>{text}</p></main></body></html>"
class TestFetchClientExtraction:
"""Tests for content extraction in FetchClient."""
def test_extract_content_readability(self, client: FetchClient) -> None:
"""Test that readability extraction works."""
html = _make_html("This is a test article with enough content to extract.")
request = FetchRequest(urls=["https://example.com"], skip_validation=True)
result = client._extract_content(html, "https://example.com", request)
assert result["text"]
assert result["title"] == "Test Page"
def test_extract_content_regex_fallback(self, client: FetchClient) -> None:
"""Test that regex extraction works as last resort."""
# Minimal HTML that may fail readability/bs4
html = "<title>Simple</title><div>Hello World</div>"
request = FetchRequest(urls=["https://example.com"], skip_validation=True)
result = client._extract_content(html, "https://example.com", request)
assert "Hello World" in result["text"] or "Simple" in result.get("title", "")
class TestFetchClientJsDetection:
"""Tests for JavaScript detection in FetchClient."""
def test_detects_enable_javascript_message(self, client: FetchClient) -> None:
"""Test that 'enable javascript' is detected."""
html = "<html><body>Please enable JavaScript to continue</body></html>"
assert client._detect_javascript_required(
html, "Please enable JavaScript to continue"
)
def test_detects_noscript_warning(self, client: FetchClient) -> None:
"""Test that noscript with javascript message is detected."""
html = (
"<html><body><noscript>You need JavaScript enabled</noscript></body></html>"
)
assert client._detect_javascript_required(html, "")
def test_no_false_positive_with_content(self, client: FetchClient) -> None:
"""Test that SPA indicators with sufficient text don't trigger."""
long_text = "Real content. " * 50
html = f'<html><body><div id="react-root">{long_text}</div></body></html>'
assert not client._detect_javascript_required(html, long_text)
def test_spa_indicator_with_short_text(self, client: FetchClient) -> None:
"""Test that SPA indicators with short text trigger detection."""
html = '<html><body><div id="react-root"></div></body></html>'
assert client._detect_javascript_required(html, "")
class TestExtractAndAnalyze:
"""Tests for combined _extract_and_analyze method."""
def test_includes_js_detected_key(self, client: FetchClient) -> None:
"""Test that _extract_and_analyze includes js_detected key."""
html = _make_html("Normal content for testing purposes.")
request = FetchRequest(urls=["https://example.com"], skip_validation=True)
result = client._extract_and_analyze(html, "https://example.com", request)
assert "js_detected" in result
assert isinstance(result["js_detected"], bool)
def test_js_detected_true_for_spa(self, client: FetchClient) -> None:
"""Test that js_detected is True for SPA pages."""
html = "<html><body><div data-reactroot></div></body></html>"
request = FetchRequest(urls=["https://example.com"], skip_validation=True)
result = client._extract_and_analyze(html, "https://example.com", request)
assert result["js_detected"] is True
class TestSkipValidation:
"""Tests for skip_validation flag in FetchClient."""
@pytest.mark.asyncio
async def test_skip_validation_skips_dns_check(self, client: FetchClient) -> None:
"""Test that skip_validation=True skips validate_urls_async."""
request = FetchRequest(
urls=["https://example.com"],
skip_validation=True,
)
with patch(
"web.fetch.client.validate_urls_async", new_callable=AsyncMock
) as mock_validate:
with patch.object(
client, "_fetch_single", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = None
with contextlib.suppress(Exception):
await client.fetch(request)
mock_validate.assert_not_called()
@pytest.mark.asyncio
async def test_no_skip_validation_calls_dns_check(
self, client: FetchClient
) -> None:
"""Test that skip_validation=False calls validate_urls_async."""
request = FetchRequest(
urls=["https://example.com"],
skip_validation=False,
)
with patch(
"web.fetch.client.validate_urls_async", new_callable=AsyncMock
) as mock_validate:
with patch.object(
client, "_fetch_single", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = None
with contextlib.suppress(Exception):
await client.fetch(request)
mock_validate.assert_called_once()
class TestHTTP2Enabled:
"""Tests for HTTP/2 support."""
@pytest.mark.asyncio
async def test_client_has_http2_enabled(self, client: FetchClient) -> None:
"""Test that the underlying httpx client has HTTP/2 enabled."""
http_client = await client._get_client()
assert http_client._transport._pool._http2 is True
@pytest.mark.asyncio
async def test_client_cleanup(self, client: FetchClient) -> None:
"""Test that client cleanup works with http2."""
_ = await client._get_client()
await client.close()
assert client._client is None
class TestFetchClientFallbackChain:
"""Tests for fallback detection in FetchClient."""
def test_needs_fallback_on_short_text(self, client: FetchClient) -> None:
"""Test that short text triggers needs_fallback."""
html = "<html><body><p>Short</p></body></html>"
request = FetchRequest(
urls=["https://example.com"],
auto_fallback=True,
min_text_length=200,
skip_validation=True,
)
result = client._extract_and_analyze(html, "https://example.com", request)
# Short text should result in needs_fallback in the page result
assert len(result["text"]) < 200

View file

@ -0,0 +1,321 @@
"""Unit tests for SearXNGClient image search with mocked httpx."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from web.config import WebSettings
from web.exceptions import ProviderError, WebTimeoutError
from web.metasearch.client import SearXNGClient
from web.schemas.image_search import ImageSearchRequest
@pytest.fixture
def settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
max_retries=2,
retry_min_wait=0.1,
retry_max_wait=1.0,
)
@pytest.fixture
def client(settings: WebSettings) -> SearXNGClient:
"""Create a SearXNGClient."""
return SearXNGClient(settings)
@pytest.fixture
def image_search_request() -> ImageSearchRequest:
"""Create a basic image search request."""
return ImageSearchRequest(queries=["test query"], max_results=5)
@pytest.fixture
def searxng_image_response() -> dict:
"""Create a mock SearXNG image search API response."""
return {
"results": [
{
"url": "https://example.com/page",
"title": "Test Image",
"content": "A test image description.",
"img_src": "https://example.com/image.jpg",
"thumbnail_src": "https://example.com/thumb.jpg",
"source": "example.com",
"resolution": "1920x1080",
},
{
"url": "https://news.example.com/gallery",
"title": "Another Image",
"content": "Another image description.",
"img_src": "https://news.example.com/photo.png",
"thumbnail_src": "https://news.example.com/thumb.png",
"source": "news.example.com",
"resolution": "800x600",
},
]
}
class TestImageSearchResults:
"""Tests for SearXNGClient.image_search."""
@pytest.mark.asyncio
async def test_image_search_returns_results(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
searxng_image_response: dict,
) -> None:
"""Test successful image search returns parsed results."""
mock_response = httpx.Response(200, json=searxng_image_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.image_search(image_search_request)
assert response.total_results == 2
assert response.results[0].image_url == "https://example.com/image.jpg"
assert response.results[0].source_url == "https://example.com/page"
assert response.results[0].thumbnail_url == "https://example.com/thumb.jpg"
assert response.results[0].title == "Test Image"
assert response.results[0].rank == 1
assert response.results[1].rank == 2
assert response.queries_processed == 1
@pytest.mark.asyncio
async def test_image_search_multiple_queries(
self,
client: SearXNGClient,
searxng_image_response: dict,
) -> None:
"""Test that multiple queries are run in parallel."""
request = ImageSearchRequest(queries=["query1", "query2"], max_results=3)
mock_response = httpx.Response(200, json=searxng_image_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.image_search(request)
assert response.queries_processed == 2
assert response.total_results == 4
assert mock_get.call_count == 2
@pytest.mark.asyncio
async def test_image_search_uses_categories_images(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
searxng_image_response: dict,
) -> None:
"""Test that image search uses categories=images parameter."""
mock_response = httpx.Response(200, json=searxng_image_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
await client.image_search(image_search_request)
call_kwargs = mock_get.call_args
params = call_kwargs.kwargs.get("params", {})
assert params.get("categories") == "images"
class TestImageSearchDimensions:
"""Tests for image dimension parsing from resolution string."""
@pytest.mark.asyncio
async def test_parses_dimensions(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
searxng_image_response: dict,
) -> None:
"""Test that width and height are correctly parsed from resolution."""
mock_response = httpx.Response(200, json=searxng_image_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.image_search(image_search_request)
assert response.results[0].width == 1920
assert response.results[0].height == 1080
assert response.results[1].width == 800
assert response.results[1].height == 600
@pytest.mark.asyncio
async def test_missing_resolution(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
) -> None:
"""Test that missing resolution defaults to None."""
searxng_response = {
"results": [
{
"url": "https://example.com/page",
"title": "No Dimensions",
"content": "",
"img_src": "https://example.com/img.jpg",
"thumbnail_src": "",
"source": "example.com",
},
]
}
mock_response = httpx.Response(200, json=searxng_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.image_search(image_search_request)
assert response.results[0].width is None
assert response.results[0].height is None
@pytest.mark.asyncio
async def test_missing_optional_fields(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
) -> None:
"""Test that missing optional fields use defaults."""
searxng_response = {
"results": [
{
"url": "https://example.com/page",
"title": "Minimal",
},
]
}
mock_response = httpx.Response(200, json=searxng_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.image_search(image_search_request)
result = response.results[0]
assert result.description == ""
assert result.thumbnail_url == ""
assert result.publisher == ""
class TestImageSearchValidation:
"""Tests for ImageSearchRequest validation."""
def test_safe_search_rejects_moderate(self) -> None:
"""Test that 'moderate' is not accepted for image safe_search."""
with pytest.raises(ValueError):
ImageSearchRequest(
queries=["test"],
safe_search="moderate", # type: ignore[arg-type]
)
def test_max_results_accepts_200(self) -> None:
"""Test that max_results=200 is accepted."""
request = ImageSearchRequest(queries=["test"], max_results=200)
assert request.max_results == 200
def test_max_results_rejects_201(self) -> None:
"""Test that max_results=201 is rejected."""
with pytest.raises(ValueError):
ImageSearchRequest(queries=["test"], max_results=201)
def test_empty_query_rejected(self) -> None:
"""Test that empty queries are rejected."""
with pytest.raises(ValueError):
ImageSearchRequest(queries=[""])
def test_too_many_queries_rejected(self) -> None:
"""Test that more than 10 queries are rejected."""
with pytest.raises(ValueError):
ImageSearchRequest(queries=[f"q{i}" for i in range(11)])
class TestImageSearchRetries:
"""Tests for retry logic with image search endpoint."""
@pytest.mark.asyncio
async def test_retries_on_timeout(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
searxng_image_response: dict,
) -> None:
"""Test that transient timeouts are retried."""
mock_response = httpx.Response(200, json=searxng_image_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = [
httpx.TimeoutException("timeout"),
mock_response,
]
response = await client.image_search(image_search_request)
assert response.total_results == 2
assert mock_get.call_count == 2
@pytest.mark.asyncio
async def test_raises_after_max_retries(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
) -> None:
"""Test that error is raised after all retries exhausted."""
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = httpx.TimeoutException("timeout")
with pytest.raises(WebTimeoutError):
await client.image_search(image_search_request)
assert mock_get.call_count == 3
@pytest.mark.asyncio
async def test_raises_provider_error_on_429(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
) -> None:
"""Test that 429 raises ProviderError without retry."""
mock_response = httpx.Response(429, json={})
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
with pytest.raises(ProviderError):
await client.image_search(image_search_request)
assert mock_get.call_count == 1
@pytest.mark.asyncio
async def test_raises_provider_error_on_500(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
) -> None:
"""Test that 500 raises ProviderError."""
mock_response = httpx.Response(500, json={})
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
with pytest.raises(ProviderError):
await client.image_search(image_search_request)
class TestImageSearchPublisher:
"""Tests for publisher/source field parsing."""
@pytest.mark.asyncio
async def test_parses_publisher(
self,
client: SearXNGClient,
image_search_request: ImageSearchRequest,
searxng_image_response: dict,
) -> None:
"""Test that publisher is extracted from source field."""
mock_response = httpx.Response(200, json=searxng_image_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.image_search(image_search_request)
assert response.results[0].publisher == "example.com"
assert response.results[1].publisher == "news.example.com"

View file

@ -0,0 +1,202 @@
"""Tests for LLMProviderChain."""
from unittest.mock import AsyncMock
import httpx
import pytest
from web.config import WebSettings
from web.exceptions import ProviderError
from web.llm.provider import LLMProviderChain, _convert_to_anthropic_messages
def _make_httpx_response(status_code: int, json: dict) -> httpx.Response:
"""Create an httpx.Response with a request set (needed for raise_for_status)."""
response = httpx.Response(status_code, json=json)
response._request = httpx.Request("POST", "http://test")
return response
@pytest.fixture
def settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
openai_api_key="test-openai-key",
anthropic_api_key="test-anthropic-key",
)
@pytest.fixture
def settings_no_keys() -> WebSettings:
"""Create test settings without API keys."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
)
class TestLLMProviderChainFallback:
"""Tests for provider fallback behavior."""
@pytest.mark.asyncio
async def test_auto_tries_local_first(self, settings: WebSettings) -> None:
"""Test that auto provider tries local first."""
mock_client = AsyncMock()
mock_client.post = AsyncMock(
return_value=_make_httpx_response(
200,
{
"choices": [{"message": {"content": "Hello"}}],
"usage": {"total_tokens": 10},
},
)
)
async def get_client():
return mock_client
chain = LLMProviderChain(settings, get_client)
text, tokens, provider, _model = await chain.call_chat(
messages=[{"role": "user", "content": "Hi"}],
provider="auto",
max_tokens=100,
)
assert provider == "local"
assert text == "Hello"
assert tokens == 10
@pytest.mark.asyncio
async def test_auto_falls_back_to_openai(self, settings: WebSettings) -> None:
"""Test fallback to OpenAI when local fails."""
mock_client = AsyncMock()
# First call (local) fails, second (openai) succeeds
mock_client.post = AsyncMock(
side_effect=[
httpx.ConnectError("refused"),
_make_httpx_response(
200,
{
"choices": [{"message": {"content": "From OpenAI"}}],
"usage": {"total_tokens": 20},
},
),
]
)
async def get_client():
return mock_client
chain = LLMProviderChain(settings, get_client)
text, _tokens, provider, _model = await chain.call_chat(
messages=[{"role": "user", "content": "Hi"}],
provider="auto",
max_tokens=100,
)
assert provider == "openai"
assert text == "From OpenAI"
@pytest.mark.asyncio
async def test_skips_providers_without_keys(
self, settings_no_keys: WebSettings
) -> None:
"""Test that providers without API keys are skipped."""
mock_client = AsyncMock()
mock_client.post = AsyncMock(
return_value=_make_httpx_response(
200,
{
"choices": [{"message": {"content": "Local"}}],
"usage": {"total_tokens": 5},
},
)
)
async def get_client():
return mock_client
chain = LLMProviderChain(settings_no_keys, get_client)
_text, _tokens, provider, _model = await chain.call_chat(
messages=[{"role": "user", "content": "Hi"}],
provider="auto",
max_tokens=100,
)
assert provider == "local"
# Only one call made (openai and anthropic skipped due to missing keys)
assert mock_client.post.call_count == 1
@pytest.mark.asyncio
async def test_raises_when_all_fail(self, settings_no_keys: WebSettings) -> None:
"""Test ProviderError when all providers fail."""
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
async def get_client():
return mock_client
chain = LLMProviderChain(settings_no_keys, get_client, domain="test")
with pytest.raises(ProviderError, match="test"):
await chain.call_chat(
messages=[{"role": "user", "content": "Hi"}],
provider="auto",
max_tokens=100,
)
@pytest.mark.asyncio
async def test_raises_no_provider_available(
self, settings_no_keys: WebSettings
) -> None:
"""Test ProviderError when no providers are available (all skipped)."""
mock_client = AsyncMock()
async def get_client():
return mock_client
chain = LLMProviderChain(settings_no_keys, get_client, domain="vision")
with pytest.raises(ProviderError, match="No vision provider available"):
await chain.call_chat(
messages=[{"role": "user", "content": "Hi"}],
provider="openai", # No key set
max_tokens=100,
)
class TestAnthropicMessageConversion:
"""Tests for OpenAI -> Anthropic message format conversion."""
def test_text_only_message(self) -> None:
"""Test conversion of text-only messages."""
messages = [{"role": "user", "content": "Hello"}]
result = _convert_to_anthropic_messages(messages)
assert result == [{"role": "user", "content": "Hello"}]
def test_multimodal_with_base64_image(self) -> None:
"""Test conversion of messages with base64 images."""
messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,AAAA"},
},
{"type": "text", "text": "Describe this."},
],
}
]
result = _convert_to_anthropic_messages(messages)
assert len(result) == 1
content = result[0]["content"]
assert len(content) == 2
assert content[0]["type"] == "image"
assert content[0]["source"]["type"] == "base64"
assert content[0]["source"]["media_type"] == "image/jpeg"
assert content[0]["source"]["data"] == "AAAA"
assert content[1]["type"] == "text"

View file

@ -0,0 +1,44 @@
"""Tests for rate limiting middleware."""
import pytest
from web.api.middleware import TokenBucket
class TestTokenBucket:
"""Tests for TokenBucket rate limiter."""
@pytest.mark.asyncio
async def test_initial_burst(self) -> None:
"""Test that initial burst tokens are available."""
bucket = TokenBucket(rate=10.0, burst=5)
# Should be able to acquire burst tokens
for _ in range(5):
allowed, _ = await bucket.acquire()
assert allowed is True
# Next one should fail
allowed, _ = await bucket.acquire()
assert allowed is False
@pytest.mark.asyncio
async def test_acquire_returns_retry_after_when_empty(self) -> None:
"""Test that acquire returns positive retry_after when empty."""
bucket = TokenBucket(rate=1.0, burst=1)
# Drain the bucket
await bucket.acquire()
allowed, retry_after = await bucket.acquire()
assert allowed is False
assert retry_after > 0.0
@pytest.mark.asyncio
async def test_acquire_returns_zero_retry_when_available(self) -> None:
"""Test that acquire returns 0 retry_after when tokens available."""
bucket = TokenBucket(rate=10.0, burst=5)
allowed, retry_after = await bucket.acquire()
assert allowed is True
assert retry_after == 0.0

View file

@ -0,0 +1,124 @@
"""Tests for the orchestrator pipeline."""
from unittest.mock import patch
import pytest
from web.config import WebSettings
from web.orchestrator import Orchestrator, _is_pdf_url
class TestPdfUrlFiltering:
"""Tests for PDF URL detection."""
def test_detects_pdf_extension(self) -> None:
"""Test that .pdf extension is detected."""
assert _is_pdf_url("https://example.com/paper.pdf") is True
def test_detects_pdf_extension_with_query(self) -> None:
"""Test that .pdf with query params is detected."""
assert _is_pdf_url("https://example.com/paper.pdf?v=1") is True
def test_ignores_non_pdf(self) -> None:
"""Test that non-PDF URLs are not flagged."""
assert _is_pdf_url("https://example.com/article.html") is False
def test_ignores_pdf_in_path(self) -> None:
"""Test that 'pdf' in the path (not extension) is not falsely flagged."""
assert _is_pdf_url("https://example.com/pdf-viewer") is False
class TestOrchestratorTimeout:
"""Tests for gather pipeline timeout enforcement."""
@pytest.fixture
def settings(self) -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
)
@pytest.mark.asyncio
async def test_gather_returns_partial_on_timeout(
self, settings: WebSettings
) -> None:
"""Test that gather returns partial results on timeout."""
from web.schemas.gather import GatherRequest
orch = Orchestrator(settings)
request = GatherRequest(
claim="Test claim for timeout verification testing",
timeout_seconds=10.0,
)
# Mock search to simulate slow response
async def slow_search(*args, **kwargs):
import asyncio
await asyncio.sleep(100) # Longer than timeout
with patch.object(orch.search_client, "search", side_effect=slow_search):
result = await orch.gather(request, request_id="test")
# Should get a timeout response, not a crash
assert result.request_id == "test"
assert len(result.stages) > 0
# Either the pipeline timed out or the search stage failed
assert any(s.error is not None for s in result.stages)
class TestScoreRelevancePropagation:
"""Tests for score_relevance derivation in orchestrator."""
def test_score_relevance_defaults_to_none(self) -> None:
"""Test that GatherRequest.score_relevance defaults to None."""
from web.schemas.gather import GatherRequest
request = GatherRequest(
claim="Test claim for score relevance defaults",
)
assert request.score_relevance is None
def test_score_relevance_none_becomes_false_without_snippets(self) -> None:
"""Test that score_relevance=None resolves to False when extract_snippets=False."""
from web.schemas.gather import GatherRequest
request = GatherRequest(
claim="Test claim for score relevance derivation",
extract_snippets=False,
)
# Mimic orchestrator derivation logic
score_rel = request.score_relevance
if score_rel is None:
score_rel = request.extract_snippets
assert score_rel is False
def test_score_relevance_none_becomes_true_with_snippets(self) -> None:
"""Test that score_relevance=None resolves to True when extract_snippets=True."""
from web.schemas.gather import GatherRequest
request = GatherRequest(
claim="Test claim for score relevance derivation",
extract_snippets=True,
)
score_rel = request.score_relevance
if score_rel is None:
score_rel = request.extract_snippets
assert score_rel is True
def test_explicit_score_relevance_overrides(self) -> None:
"""Test that explicit score_relevance=True is preserved."""
from web.schemas.gather import GatherRequest
request = GatherRequest(
claim="Test claim for explicit score relevance",
extract_snippets=False,
score_relevance=True,
)
score_rel = request.score_relevance
if score_rel is None:
score_rel = request.extract_snippets
assert score_rel is True

View file

@ -0,0 +1,90 @@
"""Tests for common schemas — FailedUrl, PageImage, make_error_detail."""
import pytest
from pydantic import ValidationError
from web.schemas.common import FailedUrl, PageImage, make_error_detail
class TestFailedUrl:
"""Tests for FailedUrl schema."""
def test_round_trip(self) -> None:
"""Test serialization round-trip."""
obj = FailedUrl(url="https://example.com", error="Timeout")
data = obj.model_dump()
assert data == {"url": "https://example.com", "error": "Timeout"}
restored = FailedUrl.model_validate(data)
assert restored == obj
def test_extra_fields_rejected(self) -> None:
"""Test that extra fields are rejected."""
with pytest.raises(ValidationError):
FailedUrl(url="https://example.com", error="Timeout", extra="bad")
def test_json_round_trip(self) -> None:
"""Test JSON serialization round-trip."""
obj = FailedUrl(url="https://a.com", error="Connection refused")
json_str = obj.model_dump_json()
restored = FailedUrl.model_validate_json(json_str)
assert restored == obj
class TestPageImage:
"""Tests for PageImage schema."""
def test_round_trip(self) -> None:
"""Test serialization round-trip."""
obj = PageImage(url="https://img.example.com/1.jpg", alt="Photo")
data = obj.model_dump()
assert data == {"url": "https://img.example.com/1.jpg", "alt": "Photo"}
restored = PageImage.model_validate(data)
assert restored == obj
def test_alt_defaults_to_none(self) -> None:
"""Test that alt text defaults to None."""
obj = PageImage(url="https://img.example.com/1.jpg")
assert obj.alt is None
def test_extra_fields_rejected(self) -> None:
"""Test that extra fields are rejected."""
with pytest.raises(ValidationError):
PageImage(url="https://img.example.com/1.jpg", width=100)
class TestMakeErrorDetail:
"""Tests for make_error_detail helper."""
def test_basic(self) -> None:
"""Test basic error detail."""
result = make_error_detail("not_found", "Resource not found")
assert result == {"error": "not_found", "message": "Resource not found"}
def test_with_request_id(self) -> None:
"""Test error detail with request_id."""
result = make_error_detail("timeout", "Request timed out", request_id="abc-123")
assert result == {
"error": "timeout",
"message": "Request timed out",
"request_id": "abc-123",
}
def test_with_extra_fields(self) -> None:
"""Test error detail with extra fields."""
result = make_error_detail(
"rate_limit",
"Too many requests",
request_id="xyz",
retry_after=30.0,
)
assert result == {
"error": "rate_limit",
"message": "Too many requests",
"request_id": "xyz",
"retry_after": 30.0,
}
def test_no_request_id_when_none(self) -> None:
"""Test that request_id is omitted when None."""
result = make_error_detail("error", "msg", request_id=None)
assert "request_id" not in result

View file

@ -0,0 +1,292 @@
"""Unit tests for SearXNGClient with mocked httpx."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from web.config import WebSettings
from web.exceptions import ProviderError, WebTimeoutError
from web.metasearch.client import SearXNGClient
from web.schemas.search import SearchRequest
@pytest.fixture
def settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
max_retries=2,
retry_min_wait=0.1,
retry_max_wait=1.0,
)
@pytest.fixture
def client(settings: WebSettings) -> SearXNGClient:
"""Create a SearXNGClient."""
return SearXNGClient(settings)
@pytest.fixture
def search_request() -> SearchRequest:
"""Create a basic search request."""
return SearchRequest(queries=["test query"], max_results=5)
@pytest.fixture
def searxng_response() -> dict:
"""Create a mock SearXNG API response."""
return {
"results": [
{
"url": "https://example.com/article",
"title": "Test Article",
"content": "A test snippet.",
"publishedDate": "2024-01-15",
},
{
"url": "https://news.example.com/story",
"title": "Another Result",
"content": "Another snippet.",
"publishedDate": None,
},
]
}
class TestSearchClientSearch:
"""Tests for SearXNGClient.search."""
@pytest.mark.asyncio
async def test_search_returns_results(
self,
client: SearXNGClient,
search_request: SearchRequest,
searxng_response: dict,
) -> None:
"""Test successful search returns parsed results."""
mock_response = httpx.Response(200, json=searxng_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.search(search_request)
assert response.total_results == 2
assert response.results[0].url == "https://example.com/article"
assert response.results[0].title == "Test Article"
assert response.results[0].rank == 1
assert response.results[1].rank == 2
assert response.queries_processed == 1
@pytest.mark.asyncio
async def test_search_multiple_queries(
self, client: SearXNGClient, searxng_response: dict
) -> None:
"""Test that multiple queries are run in parallel."""
request = SearchRequest(queries=["query1", "query2"], max_results=3)
mock_response = httpx.Response(200, json=searxng_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.search(request)
assert response.queries_processed == 2
# 2 results per query * 2 queries
assert response.total_results == 4
assert mock_get.call_count == 2
class TestSearchClientRetries:
"""Tests for retry logic in SearXNGClient."""
@pytest.mark.asyncio
async def test_retries_on_timeout(
self,
client: SearXNGClient,
search_request: SearchRequest,
searxng_response: dict,
) -> None:
"""Test that transient timeouts are retried."""
mock_response = httpx.Response(200, json=searxng_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = [
httpx.TimeoutException("timeout"),
mock_response,
]
response = await client.search(search_request)
assert response.total_results == 2
assert mock_get.call_count == 2
@pytest.mark.asyncio
async def test_retries_on_connection_error(
self,
client: SearXNGClient,
search_request: SearchRequest,
searxng_response: dict,
) -> None:
"""Test that connection errors are retried."""
mock_response = httpx.Response(200, json=searxng_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = [
httpx.ConnectError("refused"),
mock_response,
]
response = await client.search(search_request)
assert response.total_results == 2
@pytest.mark.asyncio
async def test_raises_after_max_retries(
self, client: SearXNGClient, search_request: SearchRequest
) -> None:
"""Test that error is raised after all retries exhausted."""
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = httpx.TimeoutException("timeout")
with pytest.raises(WebTimeoutError):
await client.search(search_request)
# Initial attempt + 2 retries = 3 calls
assert mock_get.call_count == 3
class TestSearchClientErrors:
"""Tests for error handling in SearXNGClient."""
@pytest.mark.asyncio
async def test_raises_provider_error_on_429(
self, client: SearXNGClient, search_request: SearchRequest
) -> None:
"""Test that 429 raises ProviderError without retry."""
mock_response = httpx.Response(429, json={})
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
with pytest.raises(ProviderError):
await client.search(search_request)
# ProviderError should not be retried
assert mock_get.call_count == 1
@pytest.mark.asyncio
async def test_raises_provider_error_on_500(
self, client: SearXNGClient, search_request: SearchRequest
) -> None:
"""Test that 500 raises ProviderError after retries are exhausted."""
mock_response = httpx.Response(500, json={})
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
with pytest.raises(ProviderError):
await client.search(search_request)
# 500 server errors should be retried (1 initial + 2 retries = 3)
assert mock_get.call_count == 3
@pytest.mark.asyncio
async def test_retries_500_then_succeeds(
self, client: SearXNGClient, search_request: SearchRequest
) -> None:
"""Test that 500 is retried and succeeds on subsequent attempt."""
fail_response = httpx.Response(500, json={})
success_response = httpx.Response(
200,
json={
"results": [
{
"url": "https://example.com/a",
"title": "Result",
"content": "Content",
}
]
},
)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = [fail_response, success_response]
response = await client.search(search_request)
assert response.total_results == 1
assert mock_get.call_count == 2
@pytest.mark.asyncio
async def test_raises_provider_error_on_403(
self, client: SearXNGClient, search_request: SearchRequest
) -> None:
"""Test that 403 raises ProviderError (not authorized)."""
mock_response = httpx.Response(403, json={})
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
with pytest.raises(ProviderError):
await client.search(search_request)
class TestSearchClientSiteFilters:
"""Tests for site filtering in SearXNGClient."""
@pytest.mark.asyncio
async def test_blocklist_filters_results(self, client: SearXNGClient) -> None:
"""Test that blocklist sites are removed from results."""
request = SearchRequest(
queries=["test"],
max_results=5,
site_blocklist=["blocked.com"],
)
searxng_response = {
"results": [
{
"url": "https://example.com/a",
"title": "Good",
"content": "Good result",
},
{
"url": "https://blocked.com/b",
"title": "Blocked",
"content": "Blocked result",
},
]
}
mock_response = httpx.Response(200, json=searxng_response)
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
response = await client.search(request)
assert response.total_results == 1
assert response.results[0].url == "https://example.com/a"
class TestSearchClientHealthCheck:
"""Tests for SearXNGClient health check."""
@pytest.mark.asyncio
async def test_health_check_returns_true_on_200(
self, client: SearXNGClient
) -> None:
"""Test that health check returns True on 200."""
mock_response = httpx.Response(200, json={})
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_returns_false_on_error(
self, client: SearXNGClient
) -> None:
"""Test that health check returns False on exception."""
with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = httpx.ConnectError("refused")
result = await client.health_check()
assert result is False

View file

@ -0,0 +1,146 @@
"""Tests for URL validation (SSRF protection)."""
from unittest.mock import AsyncMock, patch
import pytest
from web.validation import validate_url, validate_url_dns, validate_urls_async
class TestValidateUrl:
"""Tests for validate_url."""
def test_valid_https_url(self) -> None:
"""Test that a valid HTTPS URL passes."""
result = validate_url("https://example.com/article")
assert result == "https://example.com/article"
def test_valid_http_url(self) -> None:
"""Test that a valid HTTP URL passes."""
result = validate_url("http://example.com/page")
assert result == "http://example.com/page"
def test_rejects_ftp_scheme(self) -> None:
"""Test that FTP scheme is rejected."""
with pytest.raises(ValueError, match="not allowed"):
validate_url("ftp://example.com/file")
def test_rejects_file_scheme(self) -> None:
"""Test that file:// scheme is rejected."""
with pytest.raises(ValueError, match="not allowed"):
validate_url("file:///etc/passwd")
def test_rejects_javascript_scheme(self) -> None:
"""Test that javascript: scheme is rejected."""
with pytest.raises(ValueError, match="not allowed"):
validate_url("javascript:alert(1)")
def test_rejects_localhost(self) -> None:
"""Test that localhost is rejected."""
with pytest.raises(ValueError, match="not allowed"):
validate_url("http://localhost/secret")
def test_rejects_127_0_0_1(self) -> None:
"""Test that 127.0.0.1 is rejected."""
with pytest.raises(ValueError, match="private/reserved"):
validate_url("http://127.0.0.1/secret")
def test_rejects_private_ip_10(self) -> None:
"""Test that 10.x.x.x private IPs are rejected."""
with pytest.raises(ValueError, match="private/reserved"):
validate_url("http://10.0.0.1/internal")
def test_rejects_private_ip_192_168(self) -> None:
"""Test that 192.168.x.x private IPs are rejected."""
with pytest.raises(ValueError, match="private/reserved"):
validate_url("http://192.168.1.1/admin")
def test_rejects_private_ip_172_16(self) -> None:
"""Test that 172.16.x.x private IPs are rejected."""
with pytest.raises(ValueError, match="private/reserved"):
validate_url("http://172.16.0.1/internal")
def test_rejects_metadata_endpoint(self) -> None:
"""Test that cloud metadata endpoint is rejected."""
with pytest.raises(ValueError, match="not allowed"):
validate_url("http://169.254.169.254/latest/meta-data")
def test_rejects_no_hostname(self) -> None:
"""Test that URL without hostname is rejected."""
with pytest.raises(ValueError, match="hostname"):
validate_url("http:///path")
def test_rejects_empty_scheme(self) -> None:
"""Test that URL without scheme is rejected."""
with pytest.raises(ValueError, match="not allowed"):
validate_url("example.com/page")
class TestValidateUrlDns:
"""Tests for async DNS-based SSRF validation."""
@pytest.mark.asyncio
async def test_public_url_passes(self) -> None:
"""Test that a URL resolving to a public IP passes."""
# Mock getaddrinfo to return a public IP
mock_result = [(2, 1, 6, "", ("93.184.216.34", 0))]
with patch("web.validation.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value.getaddrinfo = AsyncMock(return_value=mock_result)
result = await validate_url_dns("https://example.com/article")
assert result == "https://example.com/article"
@pytest.mark.asyncio
async def test_rejects_dns_to_private_ip(self) -> None:
"""Test that a hostname resolving to a private IP is rejected."""
mock_result = [(2, 1, 6, "", ("127.0.0.1", 0))]
with patch("web.validation.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value.getaddrinfo = AsyncMock(return_value=mock_result)
with pytest.raises(ValueError, match="private/reserved"):
await validate_url_dns("https://evil.example.com/steal")
@pytest.mark.asyncio
async def test_dns_failure_rejects_url(self) -> None:
"""Test that DNS resolution failure blocks the URL (fail closed)."""
import socket
with patch("web.validation.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value.getaddrinfo = AsyncMock(
side_effect=socket.gaierror("Name or service not known")
)
with pytest.raises(ValueError, match="could not be resolved"):
await validate_url_dns("https://nonexistent.example.com/page")
@pytest.mark.asyncio
async def test_no_hostname_passes(self) -> None:
"""Test that URL with no hostname returns immediately."""
# This shouldn't happen in practice (validate_url catches it)
# but test the guard clause
result = await validate_url_dns("http:///path")
assert result == "http:///path"
class TestValidateUrlsAsync:
"""Tests for batch async URL validation."""
@pytest.mark.asyncio
async def test_validates_multiple_urls(self) -> None:
"""Test that multiple URLs are validated in parallel."""
mock_result = [(2, 1, 6, "", ("93.184.216.34", 0))]
with patch("web.validation.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value.getaddrinfo = AsyncMock(return_value=mock_result)
urls = [
"https://example.com/a",
"https://example.com/b",
"https://example.com/c",
]
result = await validate_urls_async(urls)
assert result == urls
@pytest.mark.asyncio
async def test_raises_on_private_ip_in_batch(self) -> None:
"""Test that a private IP in a batch raises."""
mock_result = [(2, 1, 6, "", ("10.0.0.1", 0))]
with patch("web.validation.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value.getaddrinfo = AsyncMock(return_value=mock_result)
with pytest.raises(ValueError, match="private/reserved"):
await validate_urls_async(["https://evil.example.com/steal"])

View file

@ -0,0 +1,210 @@
"""Unit tests for VisionClient with mocked Playwright + httpx."""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from web.config import WebSettings
def _make_httpx_response(status_code: int, json: dict) -> httpx.Response:
"""Create an httpx.Response with a request set (needed for raise_for_status)."""
response = httpx.Response(status_code, json=json)
response._request = httpx.Request("POST", "http://test")
return response
@pytest.fixture
def settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
vision_model="test-vision",
vision_max_tokens=1000,
)
class TestVisionClientImport:
"""Tests for VisionClient import handling."""
def test_raises_import_error_without_playwright(
self, settings: WebSettings
) -> None:
"""Test that VisionClient raises ImportError without playwright."""
with patch("web.vision.client.HAS_PLAYWRIGHT", False):
from web.vision.client import VisionClient
with pytest.raises(ImportError, match="playwright"):
VisionClient(settings)
class TestVisionClientExtract:
"""Tests for VisionClient extraction with mocked browser and LLM."""
@pytest.fixture
def mock_page(self) -> MagicMock:
"""Create a mock Playwright page."""
page = AsyncMock()
page.goto = AsyncMock()
page.title = AsyncMock(return_value="Vision Test Page")
page.screenshot = AsyncMock(return_value=b"\x89PNG\r\n\x00\x00")
page.evaluate = AsyncMock(return_value=[])
return page
@pytest.fixture
def mock_context(self, mock_page: MagicMock) -> MagicMock:
"""Create a mock browser context."""
context = AsyncMock()
context.new_page = AsyncMock(return_value=mock_page)
context.close = AsyncMock()
return context
@pytest.fixture
def mock_browser(self, mock_context: MagicMock) -> MagicMock:
"""Create a mock browser."""
browser = MagicMock()
browser.is_connected = MagicMock(return_value=True)
browser.new_context = AsyncMock(return_value=mock_context)
browser.close = AsyncMock()
return browser
@pytest.mark.asyncio
async def test_extract_single_page(
self, settings: WebSettings, mock_browser: MagicMock
) -> None:
"""Test extracting content from a single URL."""
with patch("web.vision.client.HAS_PLAYWRIGHT", True):
from web.schemas.vision import VisionExtractRequest
from web.vision.client import VisionClient
client = VisionClient(settings, browser=mock_browser)
# Mock the LLM call
llm_response = _make_httpx_response(
200,
json={
"choices": [
{
"message": {
"content": "Extracted text from the page screenshot."
}
}
],
"usage": {"total_tokens": 500},
},
)
with patch.object(
httpx.AsyncClient, "post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = llm_response
request = VisionExtractRequest(urls=["https://example.com/vision"])
response = await client.extract(request)
assert response.total_processed == 1
assert response.pages[0].title == "Vision Test Page"
assert "Extracted text" in response.pages[0].extracted_text
@pytest.mark.asyncio
async def test_extract_to_page_content(
self, settings: WebSettings, mock_browser: MagicMock
) -> None:
"""Test the convenience method extract_to_page_content."""
with patch("web.vision.client.HAS_PLAYWRIGHT", True):
from web.vision.client import VisionClient
client = VisionClient(settings, browser=mock_browser)
llm_response = _make_httpx_response(
200,
json={
"choices": [{"message": {"content": "Page content via vision."}}],
"usage": {"total_tokens": 300},
},
)
with patch.object(
httpx.AsyncClient, "post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = llm_response
page_content = await client.extract_to_page_content(
"https://example.com/test"
)
assert page_content.extraction_method == "vision"
assert page_content.text == "Page content via vision."
assert page_content.fallback_chain == ["http", "browse"]
@pytest.mark.asyncio
async def test_extract_handles_llm_failure(
self, settings: WebSettings, mock_browser: MagicMock
) -> None:
"""Test that LLM failure is reported properly."""
with patch("web.vision.client.HAS_PLAYWRIGHT", True):
from web.schemas.vision import VisionExtractRequest
from web.vision.client import VisionClient
client = VisionClient(settings, browser=mock_browser)
with patch.object(
httpx.AsyncClient, "post", new_callable=AsyncMock
) as mock_post:
mock_post.side_effect = httpx.ConnectError("refused")
request = VisionExtractRequest(urls=["https://example.com/fail"])
response = await client.extract(request)
assert response.total_failed == 1
assert response.total_processed == 0
@pytest.mark.asyncio
async def test_close_only_closes_owned_browser(
self, settings: WebSettings, mock_browser: MagicMock
) -> None:
"""Test that close() only closes browser if client owns it."""
with patch("web.vision.client.HAS_PLAYWRIGHT", True):
from web.vision.client import VisionClient
client = VisionClient(settings, browser=mock_browser)
await client.close()
mock_browser.close.assert_not_called()
class TestVisionClientProviderFallback:
"""Tests for LLM provider fallback in VisionClient."""
@pytest.mark.asyncio
async def test_auto_provider_tries_local_first(self, settings: WebSettings) -> None:
"""Test that auto provider tries local first."""
with patch("web.vision.client.HAS_PLAYWRIGHT", True):
from web.vision.client import VisionClient
mock_browser = MagicMock()
mock_browser.is_connected = MagicMock(return_value=True)
client = VisionClient(settings, browser=mock_browser)
llm_response = _make_httpx_response(
200,
json={
"choices": [{"message": {"content": "Text"}}],
"usage": {"total_tokens": 100},
},
)
with patch.object(
httpx.AsyncClient, "post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = llm_response
text, _tokens, provider, _model = await client._call_vision_llm(
["base64data"], None, "auto", None
)
assert provider == "local"
assert text == "Text"