A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""
|
|
Tests für Tool Gateway Service.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
import httpx
|
|
|
|
from llm_gateway.services.tool_gateway import (
|
|
ToolGateway,
|
|
ToolGatewayConfig,
|
|
SearchDepth,
|
|
SearchResult,
|
|
SearchResponse,
|
|
TavilyError,
|
|
ToolGatewayError,
|
|
get_tool_gateway,
|
|
)
|
|
from llm_gateway.services.pii_detector import PIIDetector, RedactionResult
|
|
|
|
|
|
class TestToolGatewayConfig:
|
|
"""Tests für ToolGatewayConfig."""
|
|
|
|
def test_default_config(self):
|
|
"""Test Standardkonfiguration."""
|
|
config = ToolGatewayConfig()
|
|
|
|
assert config.tavily_api_key is None
|
|
assert config.tavily_base_url == "https://api.tavily.com"
|
|
assert config.timeout == 30
|
|
assert config.max_results == 5
|
|
assert config.search_depth == SearchDepth.BASIC
|
|
assert config.include_answer is True
|
|
assert config.pii_redaction_enabled is True
|
|
|
|
def test_config_from_env(self):
|
|
"""Test Konfiguration aus Umgebungsvariablen."""
|
|
with patch.dict("os.environ", {
|
|
"TAVILY_API_KEY": "test-key",
|
|
"TAVILY_BASE_URL": "https://custom.tavily.com",
|
|
"TAVILY_TIMEOUT": "60",
|
|
"TAVILY_MAX_RESULTS": "10",
|
|
"TAVILY_SEARCH_DEPTH": "advanced",
|
|
"PII_REDACTION_ENABLED": "false",
|
|
}):
|
|
config = ToolGatewayConfig.from_env()
|
|
|
|
assert config.tavily_api_key == "test-key"
|
|
assert config.tavily_base_url == "https://custom.tavily.com"
|
|
assert config.timeout == 60
|
|
assert config.max_results == 10
|
|
assert config.search_depth == SearchDepth.ADVANCED
|
|
assert config.pii_redaction_enabled is False
|
|
|
|
|
|
class TestToolGatewayAvailability:
|
|
"""Tests für Gateway-Verfügbarkeit."""
|
|
|
|
def test_tavily_not_available_without_key(self):
|
|
"""Test Tavily nicht verfügbar ohne API Key."""
|
|
config = ToolGatewayConfig(tavily_api_key=None)
|
|
gateway = ToolGateway(config=config)
|
|
|
|
assert gateway.tavily_available is False
|
|
|
|
def test_tavily_available_with_key(self):
|
|
"""Test Tavily verfügbar mit API Key."""
|
|
config = ToolGatewayConfig(tavily_api_key="test-key")
|
|
gateway = ToolGateway(config=config)
|
|
|
|
assert gateway.tavily_available is True
|
|
|
|
|
|
class TestToolGatewayPIIRedaction:
|
|
"""Tests für PII-Redaktion im Gateway."""
|
|
|
|
def test_redact_query_with_email(self):
|
|
"""Test Redaktion von E-Mail in Query."""
|
|
config = ToolGatewayConfig(pii_redaction_enabled=True)
|
|
gateway = ToolGateway(config=config)
|
|
|
|
result = gateway._redact_query("Kontakt test@example.com Datenschutz")
|
|
|
|
assert result.pii_found is True
|
|
assert "test@example.com" not in result.redacted_text
|
|
assert "[EMAIL_REDACTED]" in result.redacted_text
|
|
|
|
def test_no_redaction_when_disabled(self):
|
|
"""Test keine Redaktion wenn deaktiviert."""
|
|
config = ToolGatewayConfig(pii_redaction_enabled=False)
|
|
gateway = ToolGateway(config=config)
|
|
|
|
result = gateway._redact_query("test@example.com")
|
|
|
|
assert result.pii_found is False
|
|
assert result.redacted_text == "test@example.com"
|
|
|
|
|
|
class TestToolGatewaySearch:
|
|
"""Tests für Suche mit Gateway."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_raises_error_without_key(self):
|
|
"""Test Fehler bei Suche ohne API Key."""
|
|
config = ToolGatewayConfig(tavily_api_key=None)
|
|
gateway = ToolGateway(config=config)
|
|
|
|
with pytest.raises(ToolGatewayError, match="not configured"):
|
|
await gateway.search("test query")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_success(self):
|
|
"""Test erfolgreiche Suche."""
|
|
config = ToolGatewayConfig(tavily_api_key="test-key")
|
|
gateway = ToolGateway(config=config)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"results": [
|
|
{
|
|
"title": "Test Result",
|
|
"url": "https://example.com",
|
|
"content": "Test content",
|
|
"score": 0.95,
|
|
}
|
|
],
|
|
"answer": "Test answer",
|
|
}
|
|
|
|
with patch.object(gateway, "_get_client") as mock_client:
|
|
mock_http = AsyncMock()
|
|
mock_http.post.return_value = mock_response
|
|
mock_client.return_value = mock_http
|
|
|
|
result = await gateway.search("Schulrecht Bayern")
|
|
|
|
assert result.query == "Schulrecht Bayern"
|
|
assert len(result.results) == 1
|
|
assert result.results[0].title == "Test Result"
|
|
assert result.answer == "Test answer"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_with_pii_redaction(self):
|
|
"""Test Suche mit PII-Redaktion."""
|
|
config = ToolGatewayConfig(
|
|
tavily_api_key="test-key",
|
|
pii_redaction_enabled=True,
|
|
)
|
|
gateway = ToolGateway(config=config)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"results": [],
|
|
"answer": None,
|
|
}
|
|
|
|
with patch.object(gateway, "_get_client") as mock_client:
|
|
mock_http = AsyncMock()
|
|
mock_http.post.return_value = mock_response
|
|
mock_client.return_value = mock_http
|
|
|
|
result = await gateway.search("Kontakt test@example.com Datenschutz")
|
|
|
|
assert result.pii_detected is True
|
|
assert "email" in result.pii_types
|
|
assert result.redacted_query is not None
|
|
assert "test@example.com" not in result.redacted_query
|
|
|
|
# Prüfen dass redaktierte Query an Tavily gesendet wurde
|
|
call_args = mock_http.post.call_args
|
|
sent_query = call_args.kwargs["json"]["query"]
|
|
assert "test@example.com" not in sent_query
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_http_error(self):
|
|
"""Test HTTP-Fehler bei Suche."""
|
|
config = ToolGatewayConfig(tavily_api_key="test-key")
|
|
gateway = ToolGateway(config=config)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 429
|
|
mock_response.text = "Rate limit exceeded"
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Rate limit",
|
|
request=MagicMock(),
|
|
response=mock_response,
|
|
)
|
|
|
|
with patch.object(gateway, "_get_client") as mock_client:
|
|
mock_http = AsyncMock()
|
|
mock_http.post.return_value = mock_response
|
|
mock_client.return_value = mock_http
|
|
|
|
with pytest.raises(TavilyError, match="429"):
|
|
await gateway.search("test")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_with_domain_filters(self):
|
|
"""Test Suche mit Domain-Filtern."""
|
|
config = ToolGatewayConfig(tavily_api_key="test-key")
|
|
gateway = ToolGateway(config=config)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"results": [], "answer": None}
|
|
|
|
with patch.object(gateway, "_get_client") as mock_client:
|
|
mock_http = AsyncMock()
|
|
mock_http.post.return_value = mock_response
|
|
mock_client.return_value = mock_http
|
|
|
|
await gateway.search(
|
|
"test",
|
|
include_domains=["gov.de", "schule.de"],
|
|
exclude_domains=["wikipedia.org"],
|
|
)
|
|
|
|
call_args = mock_http.post.call_args
|
|
payload = call_args.kwargs["json"]
|
|
assert payload["include_domains"] == ["gov.de", "schule.de"]
|
|
assert payload["exclude_domains"] == ["wikipedia.org"]
|
|
|
|
|
|
class TestSearchResult:
|
|
"""Tests für SearchResult Dataclass."""
|
|
|
|
def test_search_result_creation(self):
|
|
"""Test SearchResult erstellen."""
|
|
result = SearchResult(
|
|
title="Test",
|
|
url="https://example.com",
|
|
content="Content",
|
|
score=0.9,
|
|
published_date="2024-01-15",
|
|
)
|
|
|
|
assert result.title == "Test"
|
|
assert result.url == "https://example.com"
|
|
assert result.score == 0.9
|
|
|
|
def test_search_result_defaults(self):
|
|
"""Test SearchResult Standardwerte."""
|
|
result = SearchResult(
|
|
title="Test",
|
|
url="https://example.com",
|
|
content="Content",
|
|
)
|
|
|
|
assert result.score == 0.0
|
|
assert result.published_date is None
|
|
|
|
|
|
class TestSearchResponse:
|
|
"""Tests für SearchResponse Dataclass."""
|
|
|
|
def test_search_response_creation(self):
|
|
"""Test SearchResponse erstellen."""
|
|
response = SearchResponse(
|
|
query="test query",
|
|
results=[],
|
|
pii_detected=False,
|
|
)
|
|
|
|
assert response.query == "test query"
|
|
assert len(response.results) == 0
|
|
assert response.pii_detected is False
|
|
|
|
def test_search_response_with_pii(self):
|
|
"""Test SearchResponse mit PII."""
|
|
response = SearchResponse(
|
|
query="original query",
|
|
redacted_query="redacted query",
|
|
results=[],
|
|
pii_detected=True,
|
|
pii_types=["email", "phone"],
|
|
)
|
|
|
|
assert response.pii_detected is True
|
|
assert "email" in response.pii_types
|
|
|
|
|
|
class TestSearchDepthEnum:
|
|
"""Tests für SearchDepth Enum."""
|
|
|
|
def test_search_depth_values(self):
|
|
"""Test SearchDepth Werte."""
|
|
assert SearchDepth.BASIC.value == "basic"
|
|
assert SearchDepth.ADVANCED.value == "advanced"
|
|
|
|
|
|
class TestGetToolGatewaySingleton:
|
|
"""Tests für Singleton Pattern."""
|
|
|
|
def test_singleton_returns_same_instance(self):
|
|
"""Test dass get_tool_gateway Singleton zurückgibt."""
|
|
gateway1 = get_tool_gateway()
|
|
gateway2 = get_tool_gateway()
|
|
assert gateway1 is gateway2
|
|
|
|
|
|
class TestToolGatewayHealthCheck:
|
|
"""Tests für Health Check."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_without_tavily(self):
|
|
"""Test Health Check ohne Tavily."""
|
|
config = ToolGatewayConfig(tavily_api_key=None)
|
|
gateway = ToolGateway(config=config)
|
|
|
|
status = await gateway.health_check()
|
|
|
|
assert status["tavily"]["configured"] is False
|
|
assert status["tavily"]["healthy"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_with_tavily_success(self):
|
|
"""Test Health Check mit erfolgreichem Tavily."""
|
|
config = ToolGatewayConfig(tavily_api_key="test-key")
|
|
gateway = ToolGateway(config=config)
|
|
|
|
with patch.object(gateway, "search") as mock_search:
|
|
mock_search.return_value = SearchResponse(
|
|
query="test",
|
|
results=[],
|
|
response_time_ms=100,
|
|
)
|
|
|
|
status = await gateway.health_check()
|
|
|
|
assert status["tavily"]["configured"] is True
|
|
assert status["tavily"]["healthy"] is True
|
|
assert status["tavily"]["response_time_ms"] == 100
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_with_tavily_failure(self):
|
|
"""Test Health Check mit Tavily-Fehler."""
|
|
config = ToolGatewayConfig(tavily_api_key="test-key")
|
|
gateway = ToolGateway(config=config)
|
|
|
|
with patch.object(gateway, "search") as mock_search:
|
|
mock_search.side_effect = TavilyError("Connection failed")
|
|
|
|
status = await gateway.health_check()
|
|
|
|
assert status["tavily"]["configured"] is True
|
|
assert status["tavily"]["healthy"] is False
|
|
assert "error" in status["tavily"]
|
|
|
|
|
|
class TestToolGatewayClose:
|
|
"""Tests für Gateway-Cleanup."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_client(self):
|
|
"""Test Client-Cleanup."""
|
|
config = ToolGatewayConfig(tavily_api_key="test-key")
|
|
gateway = ToolGateway(config=config)
|
|
|
|
# Simuliere dass Client erstellt wurde
|
|
mock_client = AsyncMock()
|
|
gateway._client = mock_client
|
|
|
|
await gateway.close()
|
|
|
|
mock_client.aclose.assert_called_once()
|
|
assert gateway._client is None
|