Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue