Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
1
ai_platform/modules/web/tests/__init__.py
Normal file
1
ai_platform/modules/web/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests package."""
|
||||
115
ai_platform/modules/web/tests/conftest.py
Normal file
115
ai_platform/modules/web/tests/conftest.py
Normal 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)
|
||||
391
ai_platform/modules/web/tests/test_api.py
Normal file
391
ai_platform/modules/web/tests/test_api.py
Normal 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
|
||||
198
ai_platform/modules/web/tests/test_browse_client.py
Normal file
198
ai_platform/modules/web/tests/test_browse_client.py
Normal 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)
|
||||
100
ai_platform/modules/web/tests/test_config.py
Normal file
100
ai_platform/modules/web/tests/test_config.py
Normal 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()
|
||||
313
ai_platform/modules/web/tests/test_evidence_packer.py
Normal file
313
ai_platform/modules/web/tests/test_evidence_packer.py
Normal 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
|
||||
185
ai_platform/modules/web/tests/test_fetch_client.py
Normal file
185
ai_platform/modules/web/tests/test_fetch_client.py
Normal 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
|
||||
321
ai_platform/modules/web/tests/test_image_search_client.py
Normal file
321
ai_platform/modules/web/tests/test_image_search_client.py
Normal 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"
|
||||
202
ai_platform/modules/web/tests/test_llm_provider.py
Normal file
202
ai_platform/modules/web/tests/test_llm_provider.py
Normal 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"
|
||||
44
ai_platform/modules/web/tests/test_middleware.py
Normal file
44
ai_platform/modules/web/tests/test_middleware.py
Normal 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
|
||||
124
ai_platform/modules/web/tests/test_orchestrator.py
Normal file
124
ai_platform/modules/web/tests/test_orchestrator.py
Normal 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
|
||||
90
ai_platform/modules/web/tests/test_schemas_common.py
Normal file
90
ai_platform/modules/web/tests/test_schemas_common.py
Normal 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
|
||||
292
ai_platform/modules/web/tests/test_search_client.py
Normal file
292
ai_platform/modules/web/tests/test_search_client.py
Normal 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
|
||||
146
ai_platform/modules/web/tests/test_validation.py
Normal file
146
ai_platform/modules/web/tests/test_validation.py
Normal 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"])
|
||||
210
ai_platform/modules/web/tests/test_vision_client.py
Normal file
210
ai_platform/modules/web/tests/test_vision_client.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue