fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
3
backend/tests/test_llm_gateway/__init__.py
Normal file
3
backend/tests/test_llm_gateway/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Tests für LLM Gateway.
|
||||
"""
|
||||
501
backend/tests/test_llm_gateway/test_communication_service.py
Normal file
501
backend/tests/test_llm_gateway/test_communication_service.py
Normal file
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
Tests für den Communication Service.
|
||||
|
||||
Testet die KI-gestützte Lehrer-Eltern-Kommunikation mit GFK-Prinzipien.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from llm_gateway.services.communication_service import (
|
||||
CommunicationService,
|
||||
CommunicationType,
|
||||
CommunicationTone,
|
||||
LegalReference,
|
||||
GFKPrinciple,
|
||||
get_communication_service,
|
||||
fetch_legal_references_from_db,
|
||||
parse_db_references_to_legal_refs,
|
||||
FALLBACK_LEGAL_REFERENCES,
|
||||
GFK_PRINCIPLES,
|
||||
)
|
||||
|
||||
|
||||
class TestCommunicationType:
|
||||
"""Tests für CommunicationType Enum."""
|
||||
|
||||
def test_all_communication_types_exist(self):
|
||||
"""Test alle erwarteten Kommunikationstypen existieren."""
|
||||
expected_types = [
|
||||
"general_info",
|
||||
"behavior",
|
||||
"academic",
|
||||
"attendance",
|
||||
"meeting_invite",
|
||||
"positive_feedback",
|
||||
"concern",
|
||||
"conflict",
|
||||
"special_needs",
|
||||
]
|
||||
actual_types = [ct.value for ct in CommunicationType]
|
||||
assert set(expected_types) == set(actual_types)
|
||||
|
||||
def test_communication_type_is_string_enum(self):
|
||||
"""Test CommunicationType ist String Enum."""
|
||||
assert CommunicationType.BEHAVIOR == "behavior"
|
||||
assert CommunicationType.ACADEMIC.value == "academic"
|
||||
|
||||
|
||||
class TestCommunicationTone:
|
||||
"""Tests für CommunicationTone Enum."""
|
||||
|
||||
def test_all_tones_exist(self):
|
||||
"""Test alle Tonalitäten existieren."""
|
||||
expected_tones = ["formal", "professional", "warm", "concerned", "appreciative"]
|
||||
actual_tones = [t.value for t in CommunicationTone]
|
||||
assert set(expected_tones) == set(actual_tones)
|
||||
|
||||
|
||||
class TestLegalReference:
|
||||
"""Tests für LegalReference Dataclass."""
|
||||
|
||||
def test_legal_reference_creation(self):
|
||||
"""Test LegalReference erstellen."""
|
||||
ref = LegalReference(
|
||||
law="SchulG NRW",
|
||||
paragraph="§ 42",
|
||||
title="Pflichten der Eltern",
|
||||
summary="Eltern unterstützen die Schule.",
|
||||
relevance="Kooperationsaufforderungen",
|
||||
)
|
||||
assert ref.law == "SchulG NRW"
|
||||
assert ref.paragraph == "§ 42"
|
||||
assert "Eltern" in ref.title
|
||||
|
||||
|
||||
class TestGFKPrinciple:
|
||||
"""Tests für GFKPrinciple Dataclass."""
|
||||
|
||||
def test_gfk_principle_creation(self):
|
||||
"""Test GFKPrinciple erstellen."""
|
||||
principle = GFKPrinciple(
|
||||
principle="Beobachtung",
|
||||
description="Konkrete Handlungen beschreiben",
|
||||
example="Ich habe bemerkt...",
|
||||
)
|
||||
assert principle.principle == "Beobachtung"
|
||||
assert "beschreiben" in principle.description
|
||||
|
||||
|
||||
class TestFallbackLegalReferences:
|
||||
"""Tests für die Fallback-Referenzen."""
|
||||
|
||||
def test_default_references_exist(self):
|
||||
"""Test DEFAULT Referenzen existieren."""
|
||||
assert "DEFAULT" in FALLBACK_LEGAL_REFERENCES
|
||||
assert "elternpflichten" in FALLBACK_LEGAL_REFERENCES["DEFAULT"]
|
||||
assert "schulpflicht" in FALLBACK_LEGAL_REFERENCES["DEFAULT"]
|
||||
|
||||
def test_fallback_references_are_legal_reference(self):
|
||||
"""Test Fallback Referenzen sind LegalReference Objekte."""
|
||||
ref = FALLBACK_LEGAL_REFERENCES["DEFAULT"]["elternpflichten"]
|
||||
assert isinstance(ref, LegalReference)
|
||||
assert ref.law == "Landesschulgesetz"
|
||||
|
||||
|
||||
class TestGFKPrinciples:
|
||||
"""Tests für GFK-Prinzipien."""
|
||||
|
||||
def test_four_gfk_principles_exist(self):
|
||||
"""Test alle 4 GFK-Prinzipien existieren."""
|
||||
assert len(GFK_PRINCIPLES) == 4
|
||||
principles = [p.principle for p in GFK_PRINCIPLES]
|
||||
assert "Beobachtung" in principles
|
||||
assert "Gefühle" in principles
|
||||
assert "Bedürfnisse" in principles
|
||||
assert "Bitten" in principles
|
||||
|
||||
|
||||
class TestCommunicationService:
|
||||
"""Tests für CommunicationService Klasse."""
|
||||
|
||||
def test_service_initialization(self):
|
||||
"""Test Service wird korrekt initialisiert."""
|
||||
service = CommunicationService()
|
||||
assert service.fallback_references is not None
|
||||
assert service.gfk_principles is not None
|
||||
assert service.templates is not None
|
||||
assert service._cached_references == {}
|
||||
|
||||
def test_get_legal_references_sync(self):
|
||||
"""Test synchrone get_legal_references Methode (Fallback)."""
|
||||
service = CommunicationService()
|
||||
refs = service.get_legal_references("NRW", "elternpflichten")
|
||||
|
||||
assert len(refs) > 0
|
||||
assert isinstance(refs[0], LegalReference)
|
||||
|
||||
def test_get_fallback_references(self):
|
||||
"""Test _get_fallback_references Methode."""
|
||||
service = CommunicationService()
|
||||
refs = service._get_fallback_references("DEFAULT", "elternpflichten")
|
||||
|
||||
assert len(refs) == 1
|
||||
assert refs[0].law == "Landesschulgesetz"
|
||||
|
||||
def test_get_gfk_guidance(self):
|
||||
"""Test get_gfk_guidance gibt GFK-Prinzipien zurück."""
|
||||
service = CommunicationService()
|
||||
guidance = service.get_gfk_guidance(CommunicationType.BEHAVIOR)
|
||||
|
||||
assert len(guidance) == 4
|
||||
assert all(isinstance(g, GFKPrinciple) for g in guidance)
|
||||
|
||||
def test_get_template(self):
|
||||
"""Test get_template gibt Vorlage zurück."""
|
||||
service = CommunicationService()
|
||||
template = service.get_template(CommunicationType.MEETING_INVITE)
|
||||
|
||||
assert "subject" in template
|
||||
assert "opening" in template
|
||||
assert "closing" in template
|
||||
assert "Einladung" in template["subject"]
|
||||
|
||||
def test_get_template_fallback(self):
|
||||
"""Test get_template Fallback zu GENERAL_INFO."""
|
||||
service = CommunicationService()
|
||||
# Unbekannter Typ sollte auf GENERAL_INFO fallen
|
||||
template = service.get_template(CommunicationType.GENERAL_INFO)
|
||||
|
||||
assert "Information" in template["subject"]
|
||||
|
||||
|
||||
class TestCommunicationServiceAsync:
|
||||
"""Async Tests für CommunicationService."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_legal_references_async_with_db(self):
|
||||
"""Test async get_legal_references_async mit DB-Daten."""
|
||||
service = CommunicationService()
|
||||
|
||||
# Mock die fetch_legal_references_from_db Funktion
|
||||
mock_docs = [
|
||||
{
|
||||
"law_name": "SchulG NRW",
|
||||
"title": "Schulgesetz NRW",
|
||||
"paragraphs": [
|
||||
{"nr": "§ 42", "title": "Pflichten der Eltern"},
|
||||
{"nr": "§ 41", "title": "Schulpflicht"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
with patch(
|
||||
"llm_gateway.services.communication_service.fetch_legal_references_from_db",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_fetch:
|
||||
mock_fetch.return_value = mock_docs
|
||||
|
||||
refs = await service.get_legal_references_async("NRW", "elternpflichten")
|
||||
|
||||
assert len(refs) > 0
|
||||
mock_fetch.assert_called_once_with("NRW")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_legal_references_async_fallback(self):
|
||||
"""Test async get_legal_references_async Fallback wenn DB leer."""
|
||||
service = CommunicationService()
|
||||
|
||||
with patch(
|
||||
"llm_gateway.services.communication_service.fetch_legal_references_from_db",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_fetch:
|
||||
mock_fetch.return_value = [] # Leere DB
|
||||
|
||||
refs = await service.get_legal_references_async("NRW", "elternpflichten")
|
||||
|
||||
# Sollte Fallback nutzen
|
||||
assert len(refs) > 0
|
||||
assert refs[0].law == "Landesschulgesetz"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_legal_references_async_caching(self):
|
||||
"""Test dass Ergebnisse gecached werden."""
|
||||
service = CommunicationService()
|
||||
|
||||
mock_docs = [
|
||||
{
|
||||
"law_name": "SchulG NRW",
|
||||
"paragraphs": [{"nr": "§ 42", "title": "Pflichten der Eltern"}],
|
||||
}
|
||||
]
|
||||
|
||||
with patch(
|
||||
"llm_gateway.services.communication_service.fetch_legal_references_from_db",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_fetch:
|
||||
mock_fetch.return_value = mock_docs
|
||||
|
||||
# Erster Aufruf
|
||||
await service.get_legal_references_async("NRW", "elternpflichten")
|
||||
|
||||
# Zweiter Aufruf sollte Cache nutzen
|
||||
await service.get_legal_references_async("NRW", "elternpflichten")
|
||||
|
||||
# fetch sollte nur einmal aufgerufen werden
|
||||
assert mock_fetch.call_count == 1
|
||||
|
||||
|
||||
class TestFetchLegalReferencesFromDB:
|
||||
"""Tests für fetch_legal_references_from_db Funktion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_success(self):
|
||||
"""Test erfolgreicher API-Aufruf."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"documents": [
|
||||
{"law_name": "SchulG NRW", "paragraphs": []},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.return_value = mock_response
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
mock_instance.__aexit__.return_value = None
|
||||
mock_client.return_value = mock_instance
|
||||
|
||||
docs = await fetch_legal_references_from_db("NRW")
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0]["law_name"] == "SchulG NRW"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_api_error(self):
|
||||
"""Test API-Fehler gibt leere Liste zurück."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.return_value = mock_response
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
mock_instance.__aexit__.return_value = None
|
||||
mock_client.return_value = mock_instance
|
||||
|
||||
docs = await fetch_legal_references_from_db("NRW")
|
||||
|
||||
assert docs == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_network_error(self):
|
||||
"""Test Netzwerkfehler gibt leere Liste zurück."""
|
||||
import httpx
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.side_effect = httpx.ConnectError("Connection failed")
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
mock_instance.__aexit__.return_value = None
|
||||
mock_client.return_value = mock_instance
|
||||
|
||||
docs = await fetch_legal_references_from_db("NRW")
|
||||
|
||||
assert docs == []
|
||||
|
||||
|
||||
class TestParseDbReferencesToLegalRefs:
|
||||
"""Tests für parse_db_references_to_legal_refs Funktion."""
|
||||
|
||||
def test_parse_with_matching_paragraphs(self):
|
||||
"""Test Parsing mit passenden Paragraphen."""
|
||||
db_docs = [
|
||||
{
|
||||
"law_name": "SchulG NRW",
|
||||
"title": "Schulgesetz",
|
||||
"paragraphs": [
|
||||
{"nr": "§ 42", "title": "Pflichten der Eltern"},
|
||||
{"nr": "§ 1", "title": "Bildungsauftrag"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
refs = parse_db_references_to_legal_refs(db_docs, "elternpflichten")
|
||||
|
||||
assert len(refs) > 0
|
||||
# § 42 sollte für elternpflichten relevant sein
|
||||
assert any("42" in r.paragraph for r in refs)
|
||||
|
||||
def test_parse_without_paragraphs(self):
|
||||
"""Test Parsing ohne Paragraphen."""
|
||||
db_docs = [
|
||||
{
|
||||
"law_name": "SchulG NRW",
|
||||
"title": "Schulgesetz",
|
||||
"paragraphs": [],
|
||||
}
|
||||
]
|
||||
|
||||
refs = parse_db_references_to_legal_refs(db_docs, "elternpflichten")
|
||||
|
||||
# Sollte trotzdem Referenz erstellen
|
||||
assert len(refs) == 1
|
||||
assert refs[0].law == "SchulG NRW"
|
||||
|
||||
def test_parse_empty_docs(self):
|
||||
"""Test Parsing mit leerer Dokumentenliste."""
|
||||
refs = parse_db_references_to_legal_refs([], "elternpflichten")
|
||||
|
||||
assert refs == []
|
||||
|
||||
|
||||
class TestBuildSystemPrompt:
|
||||
"""Tests für build_system_prompt Methode."""
|
||||
|
||||
def test_build_system_prompt_contains_gfk(self):
|
||||
"""Test System-Prompt enthält GFK-Prinzipien."""
|
||||
service = CommunicationService()
|
||||
prompt = service.build_system_prompt(
|
||||
CommunicationType.BEHAVIOR,
|
||||
"NRW",
|
||||
CommunicationTone.PROFESSIONAL,
|
||||
)
|
||||
|
||||
# Prüfe Großbuchstaben-Varianten (wie im Prompt verwendet)
|
||||
assert "BEOBACHTUNG" in prompt
|
||||
assert "GEFÜHLE" in prompt
|
||||
assert "BEDÜRFNISSE" in prompt
|
||||
assert "BITTEN" in prompt
|
||||
|
||||
def test_build_system_prompt_contains_tone(self):
|
||||
"""Test System-Prompt enthält Tonalität."""
|
||||
service = CommunicationService()
|
||||
prompt = service.build_system_prompt(
|
||||
CommunicationType.BEHAVIOR,
|
||||
"NRW",
|
||||
CommunicationTone.WARM,
|
||||
)
|
||||
|
||||
assert "warmherzig" in prompt.lower()
|
||||
|
||||
|
||||
class TestBuildUserPrompt:
|
||||
"""Tests für build_user_prompt Methode."""
|
||||
|
||||
def test_build_user_prompt_with_context(self):
|
||||
"""Test User-Prompt mit Kontext."""
|
||||
service = CommunicationService()
|
||||
prompt = service.build_user_prompt(
|
||||
CommunicationType.BEHAVIOR,
|
||||
{
|
||||
"student_name": "Max",
|
||||
"parent_name": "Frau Müller",
|
||||
"situation": "Max stört häufig den Unterricht.",
|
||||
"additional_info": "Bereits 3x ermahnt.",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Max" in prompt
|
||||
assert "Frau Müller" in prompt
|
||||
assert "stört" in prompt
|
||||
assert "ermahnt" in prompt
|
||||
|
||||
|
||||
class TestValidateCommunication:
|
||||
"""Tests für validate_communication Methode."""
|
||||
|
||||
def test_validate_good_communication(self):
|
||||
"""Test Validierung einer guten Kommunikation."""
|
||||
service = CommunicationService()
|
||||
text = """
|
||||
Sehr geehrte Frau Müller,
|
||||
|
||||
ich habe bemerkt, dass Max in letzter Zeit häufiger abwesend war.
|
||||
Ich möchte Sie gerne zu einem Gespräch einladen, da mir eine gute
|
||||
Zusammenarbeit sehr wichtig ist.
|
||||
|
||||
Wären Sie bereit, nächste Woche zu einem Termin zu kommen?
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
"""
|
||||
|
||||
result = service.validate_communication(text)
|
||||
|
||||
assert result["is_valid"] is True
|
||||
assert len(result["positive_elements"]) > 0
|
||||
assert result["gfk_score"] > 0.5
|
||||
|
||||
def test_validate_bad_communication(self):
|
||||
"""Test Validierung einer problematischen Kommunikation."""
|
||||
service = CommunicationService()
|
||||
text = """
|
||||
Sehr geehrte Frau Müller,
|
||||
|
||||
Sie müssen endlich etwas tun! Das Kind ist faul und respektlos.
|
||||
Sie sollten mehr kontrollieren.
|
||||
"""
|
||||
|
||||
result = service.validate_communication(text)
|
||||
|
||||
assert result["is_valid"] is False
|
||||
assert len(result["issues"]) > 0
|
||||
assert len(result["suggestions"]) > 0
|
||||
|
||||
|
||||
class TestGetAllCommunicationTypes:
|
||||
"""Tests für get_all_communication_types Methode."""
|
||||
|
||||
def test_returns_all_types(self):
|
||||
"""Test gibt alle Typen zurück."""
|
||||
service = CommunicationService()
|
||||
types = service.get_all_communication_types()
|
||||
|
||||
assert len(types) == 9
|
||||
assert all("value" in t and "label" in t for t in types)
|
||||
|
||||
|
||||
class TestGetAllTones:
|
||||
"""Tests für get_all_tones Methode."""
|
||||
|
||||
def test_returns_all_tones(self):
|
||||
"""Test gibt alle Tonalitäten zurück."""
|
||||
service = CommunicationService()
|
||||
tones = service.get_all_tones()
|
||||
|
||||
assert len(tones) == 5
|
||||
assert all("value" in t and "label" in t for t in tones)
|
||||
|
||||
|
||||
class TestGetStates:
|
||||
"""Tests für get_states Methode."""
|
||||
|
||||
def test_returns_all_16_bundeslaender(self):
|
||||
"""Test gibt alle 16 Bundesländer zurück."""
|
||||
service = CommunicationService()
|
||||
states = service.get_states()
|
||||
|
||||
assert len(states) == 16
|
||||
# Prüfe einige
|
||||
state_values = [s["value"] for s in states]
|
||||
assert "NRW" in state_values
|
||||
assert "BY" in state_values
|
||||
assert "BE" in state_values
|
||||
|
||||
|
||||
class TestGetCommunicationService:
|
||||
"""Tests für Singleton-Pattern."""
|
||||
|
||||
def test_singleton_pattern(self):
|
||||
"""Test dass get_communication_service immer dieselbe Instanz zurückgibt."""
|
||||
service1 = get_communication_service()
|
||||
service2 = get_communication_service()
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
def test_returns_communication_service(self):
|
||||
"""Test dass CommunicationService zurückgegeben wird."""
|
||||
service = get_communication_service()
|
||||
|
||||
assert isinstance(service, CommunicationService)
|
||||
175
backend/tests/test_llm_gateway/test_config.py
Normal file
175
backend/tests/test_llm_gateway/test_config.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Tests für LLM Gateway Config.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
from llm_gateway.config import (
|
||||
GatewayConfig,
|
||||
LLMBackendConfig,
|
||||
load_config,
|
||||
get_config,
|
||||
)
|
||||
|
||||
|
||||
class TestGatewayConfig:
|
||||
"""Tests für GatewayConfig Dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test Standardwerte."""
|
||||
config = GatewayConfig()
|
||||
assert config.host == "0.0.0.0"
|
||||
assert config.port == 8002
|
||||
assert config.debug is False
|
||||
assert config.rate_limit_requests_per_minute == 60
|
||||
assert config.log_level == "INFO"
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test benutzerdefinierte Werte."""
|
||||
config = GatewayConfig(
|
||||
host="127.0.0.1",
|
||||
port=9000,
|
||||
debug=True,
|
||||
rate_limit_requests_per_minute=100,
|
||||
)
|
||||
assert config.host == "127.0.0.1"
|
||||
assert config.port == 9000
|
||||
assert config.debug is True
|
||||
assert config.rate_limit_requests_per_minute == 100
|
||||
|
||||
|
||||
class TestLLMBackendConfig:
|
||||
"""Tests für LLMBackendConfig."""
|
||||
|
||||
def test_minimal_config(self):
|
||||
"""Test minimale Backend-Konfiguration."""
|
||||
config = LLMBackendConfig(
|
||||
name="test",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
assert config.name == "test"
|
||||
assert config.base_url == "http://localhost:8000"
|
||||
assert config.api_key is None
|
||||
assert config.enabled is True
|
||||
|
||||
def test_full_config(self):
|
||||
"""Test vollständige Backend-Konfiguration."""
|
||||
config = LLMBackendConfig(
|
||||
name="vllm",
|
||||
base_url="http://gpu-server:8000",
|
||||
api_key="secret-key",
|
||||
default_model="llama-3.1-8b",
|
||||
timeout=180,
|
||||
enabled=True,
|
||||
)
|
||||
assert config.api_key == "secret-key"
|
||||
assert config.default_model == "llama-3.1-8b"
|
||||
assert config.timeout == 180
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Tests für load_config Funktion."""
|
||||
|
||||
def test_load_config_defaults(self):
|
||||
"""Test Laden mit Standardwerten."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
config = load_config()
|
||||
assert config.host == "0.0.0.0"
|
||||
assert config.port == 8002
|
||||
assert config.debug is False
|
||||
|
||||
def test_load_config_with_env_vars(self):
|
||||
"""Test Laden mit Umgebungsvariablen."""
|
||||
env = {
|
||||
"LLM_GATEWAY_HOST": "127.0.0.1",
|
||||
"LLM_GATEWAY_PORT": "9000",
|
||||
"LLM_GATEWAY_DEBUG": "true",
|
||||
"LLM_RATE_LIMIT_RPM": "120",
|
||||
"LLM_LOG_LEVEL": "DEBUG",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
config = load_config()
|
||||
assert config.host == "127.0.0.1"
|
||||
assert config.port == 9000
|
||||
assert config.debug is True
|
||||
assert config.rate_limit_requests_per_minute == 120
|
||||
assert config.log_level == "DEBUG"
|
||||
|
||||
def test_load_config_ollama_backend(self):
|
||||
"""Test Ollama Backend Konfiguration."""
|
||||
env = {
|
||||
"OLLAMA_BASE_URL": "http://localhost:11434",
|
||||
"OLLAMA_DEFAULT_MODEL": "mistral:7b",
|
||||
"OLLAMA_TIMEOUT": "60",
|
||||
"OLLAMA_ENABLED": "true",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
config = load_config()
|
||||
assert config.ollama is not None
|
||||
assert config.ollama.base_url == "http://localhost:11434"
|
||||
assert config.ollama.default_model == "mistral:7b"
|
||||
assert config.ollama.timeout == 60
|
||||
assert config.ollama.enabled is True
|
||||
|
||||
def test_load_config_vllm_backend(self):
|
||||
"""Test vLLM Backend Konfiguration."""
|
||||
env = {
|
||||
"VLLM_BASE_URL": "http://gpu-server:8000",
|
||||
"VLLM_API_KEY": "secret-key",
|
||||
"VLLM_DEFAULT_MODEL": "meta-llama/Llama-3.1-8B-Instruct",
|
||||
"VLLM_ENABLED": "true",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
config = load_config()
|
||||
assert config.vllm is not None
|
||||
assert config.vllm.base_url == "http://gpu-server:8000"
|
||||
assert config.vllm.api_key == "secret-key"
|
||||
assert config.vllm.enabled is True
|
||||
|
||||
def test_load_config_anthropic_backend(self):
|
||||
"""Test Anthropic Backend Konfiguration."""
|
||||
env = {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-xxx",
|
||||
"ANTHROPIC_DEFAULT_MODEL": "claude-3-5-sonnet-20241022",
|
||||
"ANTHROPIC_ENABLED": "true",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
config = load_config()
|
||||
assert config.anthropic is not None
|
||||
assert config.anthropic.api_key == "sk-ant-xxx"
|
||||
assert config.anthropic.default_model == "claude-3-5-sonnet-20241022"
|
||||
assert config.anthropic.enabled is True
|
||||
|
||||
def test_load_config_no_anthropic_without_key(self):
|
||||
"""Test dass Anthropic ohne Key nicht konfiguriert wird."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
config = load_config()
|
||||
assert config.anthropic is None
|
||||
|
||||
def test_load_config_backend_priority(self):
|
||||
"""Test Backend Priorität."""
|
||||
env = {
|
||||
"LLM_BACKEND_PRIORITY": "vllm,anthropic,ollama",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
config = load_config()
|
||||
assert config.backend_priority == ["vllm", "anthropic", "ollama"]
|
||||
|
||||
def test_load_config_api_keys(self):
|
||||
"""Test API Keys Liste."""
|
||||
env = {
|
||||
"LLM_API_KEYS": "key1,key2,key3",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
config = load_config()
|
||||
assert config.api_keys == ["key1", "key2", "key3"]
|
||||
|
||||
def test_load_config_jwt_secret(self):
|
||||
"""Test JWT Secret."""
|
||||
env = {
|
||||
"JWT_SECRET": "my-secret-key",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
config = load_config()
|
||||
assert config.jwt_secret == "my-secret-key"
|
||||
195
backend/tests/test_llm_gateway/test_inference_service.py
Normal file
195
backend/tests/test_llm_gateway/test_inference_service.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Tests für Inference Service.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from llm_gateway.services.inference import (
|
||||
InferenceService,
|
||||
InferenceResult,
|
||||
get_inference_service,
|
||||
)
|
||||
from llm_gateway.models.chat import (
|
||||
ChatCompletionRequest,
|
||||
ChatMessage,
|
||||
Usage,
|
||||
)
|
||||
|
||||
|
||||
class TestInferenceServiceModelMapping:
|
||||
"""Tests für Model Mapping."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.service = InferenceService()
|
||||
|
||||
def test_map_breakpilot_model_to_ollama(self):
|
||||
"""Test Mapping von BreakPilot Modell zu Ollama."""
|
||||
# Mock Ollama als verfügbares Backend
|
||||
with patch.object(self.service, 'config') as mock_config:
|
||||
mock_config.ollama = MagicMock()
|
||||
mock_config.ollama.name = "ollama"
|
||||
mock_config.ollama.enabled = True
|
||||
mock_config.vllm = None
|
||||
mock_config.anthropic = None
|
||||
mock_config.backend_priority = ["ollama", "vllm", "anthropic"]
|
||||
|
||||
actual_model, backend = self.service._map_model_to_backend("breakpilot-teacher-8b")
|
||||
assert actual_model == "llama3.1:8b"
|
||||
assert backend.name == "ollama"
|
||||
|
||||
def test_map_breakpilot_70b_model(self):
|
||||
"""Test Mapping von 70B Modell."""
|
||||
with patch.object(self.service, 'config') as mock_config:
|
||||
mock_config.ollama = MagicMock()
|
||||
mock_config.ollama.name = "ollama"
|
||||
mock_config.ollama.enabled = True
|
||||
mock_config.vllm = None
|
||||
mock_config.anthropic = None
|
||||
mock_config.backend_priority = ["ollama"]
|
||||
|
||||
actual_model, backend = self.service._map_model_to_backend("breakpilot-teacher-70b")
|
||||
assert "70b" in actual_model.lower()
|
||||
|
||||
def test_map_claude_model_to_anthropic(self):
|
||||
"""Test Mapping von Claude Modell zu Anthropic."""
|
||||
with patch.object(self.service, 'config') as mock_config:
|
||||
mock_config.ollama = None
|
||||
mock_config.vllm = None
|
||||
mock_config.anthropic = MagicMock()
|
||||
mock_config.anthropic.name = "anthropic"
|
||||
mock_config.anthropic.enabled = True
|
||||
mock_config.anthropic.default_model = "claude-3-5-sonnet-20241022"
|
||||
mock_config.backend_priority = ["anthropic"]
|
||||
|
||||
actual_model, backend = self.service._map_model_to_backend("claude-3-5-sonnet")
|
||||
assert backend.name == "anthropic"
|
||||
assert "claude" in actual_model.lower()
|
||||
|
||||
def test_map_model_no_backend_available(self):
|
||||
"""Test Fehler wenn kein Backend verfügbar."""
|
||||
with patch.object(self.service, 'config') as mock_config:
|
||||
mock_config.ollama = None
|
||||
mock_config.vllm = None
|
||||
mock_config.anthropic = None
|
||||
mock_config.backend_priority = []
|
||||
|
||||
with pytest.raises(ValueError, match="No LLM backend available"):
|
||||
self.service._map_model_to_backend("breakpilot-teacher-8b")
|
||||
|
||||
|
||||
class TestInferenceServiceBackendSelection:
|
||||
"""Tests für Backend-Auswahl."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.service = InferenceService()
|
||||
|
||||
def test_get_available_backend_priority(self):
|
||||
"""Test Backend-Auswahl nach Priorität."""
|
||||
with patch.object(self.service, 'config') as mock_config:
|
||||
# Beide Backends verfügbar
|
||||
mock_config.ollama = MagicMock()
|
||||
mock_config.ollama.enabled = True
|
||||
mock_config.vllm = MagicMock()
|
||||
mock_config.vllm.enabled = True
|
||||
mock_config.anthropic = None
|
||||
mock_config.backend_priority = ["vllm", "ollama"]
|
||||
|
||||
backend = self.service._get_available_backend()
|
||||
# vLLM hat höhere Priorität
|
||||
assert backend == mock_config.vllm
|
||||
|
||||
def test_get_available_backend_fallback(self):
|
||||
"""Test Fallback wenn primäres Backend nicht verfügbar."""
|
||||
with patch.object(self.service, 'config') as mock_config:
|
||||
mock_config.ollama = MagicMock()
|
||||
mock_config.ollama.enabled = True
|
||||
mock_config.vllm = MagicMock()
|
||||
mock_config.vllm.enabled = False # Deaktiviert
|
||||
mock_config.anthropic = None
|
||||
mock_config.backend_priority = ["vllm", "ollama"]
|
||||
|
||||
backend = self.service._get_available_backend()
|
||||
# Ollama als Fallback
|
||||
assert backend == mock_config.ollama
|
||||
|
||||
def test_get_available_backend_none_available(self):
|
||||
"""Test wenn kein Backend verfügbar."""
|
||||
with patch.object(self.service, 'config') as mock_config:
|
||||
mock_config.ollama = None
|
||||
mock_config.vllm = None
|
||||
mock_config.anthropic = None
|
||||
mock_config.backend_priority = ["ollama", "vllm", "anthropic"]
|
||||
|
||||
backend = self.service._get_available_backend()
|
||||
assert backend is None
|
||||
|
||||
|
||||
class TestInferenceResult:
|
||||
"""Tests für InferenceResult Dataclass."""
|
||||
|
||||
def test_inference_result_creation(self):
|
||||
"""Test InferenceResult erstellen."""
|
||||
result = InferenceResult(
|
||||
content="Hello, world!",
|
||||
model="llama3.1:8b",
|
||||
backend="ollama",
|
||||
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||
finish_reason="stop",
|
||||
)
|
||||
assert result.content == "Hello, world!"
|
||||
assert result.model == "llama3.1:8b"
|
||||
assert result.backend == "ollama"
|
||||
assert result.usage.total_tokens == 15
|
||||
|
||||
def test_inference_result_defaults(self):
|
||||
"""Test Standardwerte."""
|
||||
result = InferenceResult(
|
||||
content="Test",
|
||||
model="test",
|
||||
backend="test",
|
||||
)
|
||||
assert result.usage is None
|
||||
assert result.finish_reason == "stop"
|
||||
|
||||
|
||||
class TestInferenceServiceComplete:
|
||||
"""Tests für complete() Methode."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_calls_correct_backend(self):
|
||||
"""Test dass correct Backend aufgerufen wird."""
|
||||
service = InferenceService()
|
||||
|
||||
request = ChatCompletionRequest(
|
||||
model="breakpilot-teacher-8b",
|
||||
messages=[ChatMessage(role="user", content="Hello")],
|
||||
)
|
||||
|
||||
# Mock das Backend
|
||||
with patch.object(service, '_map_model_to_backend') as mock_map:
|
||||
with patch.object(service, '_call_ollama') as mock_call:
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.name = "ollama"
|
||||
mock_map.return_value = ("llama3.1:8b", mock_backend)
|
||||
mock_call.return_value = InferenceResult(
|
||||
content="Hello!",
|
||||
model="llama3.1:8b",
|
||||
backend="ollama",
|
||||
)
|
||||
|
||||
response = await service.complete(request)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
assert response.choices[0].message.content == "Hello!"
|
||||
|
||||
|
||||
class TestGetInferenceServiceSingleton:
|
||||
"""Tests für Singleton Pattern."""
|
||||
|
||||
def test_singleton_returns_same_instance(self):
|
||||
"""Test dass get_inference_service Singleton zurückgibt."""
|
||||
service1 = get_inference_service()
|
||||
service2 = get_inference_service()
|
||||
assert service1 is service2
|
||||
237
backend/tests/test_llm_gateway/test_legal_crawler.py
Normal file
237
backend/tests/test_llm_gateway/test_legal_crawler.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Tests für den Legal Crawler Service.
|
||||
|
||||
Testet das Crawlen und Parsen von rechtlichen Bildungsinhalten.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import httpx
|
||||
|
||||
from llm_gateway.services.legal_crawler import (
|
||||
LegalCrawler,
|
||||
CrawledDocument,
|
||||
get_legal_crawler,
|
||||
)
|
||||
|
||||
|
||||
class TestLegalCrawler:
|
||||
"""Tests für LegalCrawler Klasse."""
|
||||
|
||||
def test_crawler_initialization(self):
|
||||
"""Test Crawler wird korrekt initialisiert."""
|
||||
crawler = LegalCrawler()
|
||||
assert crawler.user_agent == "BreakPilot-Crawler/1.0 (Educational Purpose)"
|
||||
assert crawler.timeout == 30.0
|
||||
assert crawler.rate_limit_delay == 1.0
|
||||
assert crawler.db_pool is None
|
||||
|
||||
def test_crawler_with_db_pool(self):
|
||||
"""Test Crawler mit DB Pool."""
|
||||
mock_pool = MagicMock()
|
||||
crawler = LegalCrawler(db_pool=mock_pool)
|
||||
assert crawler.db_pool == mock_pool
|
||||
|
||||
|
||||
class TestCrawledDocument:
|
||||
"""Tests für CrawledDocument Dataclass."""
|
||||
|
||||
def test_document_creation(self):
|
||||
"""Test CrawledDocument erstellen."""
|
||||
doc = CrawledDocument(
|
||||
url="https://example.com/schulgesetz",
|
||||
canonical_url="https://example.com/schulgesetz",
|
||||
title="Schulgesetz NRW",
|
||||
content="§ 1 Bildungsauftrag...",
|
||||
content_hash="abc123",
|
||||
category="legal",
|
||||
doc_type="schulgesetz",
|
||||
state="NW",
|
||||
law_name="SchulG NRW",
|
||||
paragraphs=[{"nr": "§ 1", "title": "Bildungsauftrag"}],
|
||||
trust_score=0.9,
|
||||
)
|
||||
assert doc.url == "https://example.com/schulgesetz"
|
||||
assert doc.state == "NW"
|
||||
assert doc.law_name == "SchulG NRW"
|
||||
assert len(doc.paragraphs) == 1
|
||||
|
||||
def test_document_without_optional_fields(self):
|
||||
"""Test CrawledDocument ohne optionale Felder."""
|
||||
doc = CrawledDocument(
|
||||
url="https://example.com/info",
|
||||
canonical_url=None,
|
||||
title="Info Page",
|
||||
content="Some content",
|
||||
content_hash="def456",
|
||||
category="legal",
|
||||
doc_type="info",
|
||||
state=None,
|
||||
law_name=None,
|
||||
paragraphs=None,
|
||||
trust_score=0.5,
|
||||
)
|
||||
assert doc.state is None
|
||||
assert doc.paragraphs is None
|
||||
|
||||
|
||||
class TestParagraphExtraction:
|
||||
"""Tests für die Paragraphen-Extraktion."""
|
||||
|
||||
def test_extract_paragraphs_from_html(self):
|
||||
"""Test Paragraphen werden aus HTML extrahiert."""
|
||||
crawler = LegalCrawler()
|
||||
html_content = """
|
||||
§ 1 Bildungsauftrag
|
||||
Die Schule hat den Auftrag...
|
||||
|
||||
§ 2 Erziehungsauftrag
|
||||
Die Schule erzieht...
|
||||
|
||||
§ 42 Pflichten der Eltern
|
||||
Die Eltern sind verpflichtet...
|
||||
"""
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup("<body></body>", "html.parser")
|
||||
|
||||
paragraphs = crawler._extract_paragraphs(soup, html_content)
|
||||
|
||||
assert paragraphs is not None
|
||||
assert len(paragraphs) >= 3
|
||||
# Prüfe dass § 42 gefunden wurde
|
||||
para_numbers = [p["nr"] for p in paragraphs]
|
||||
assert any("42" in nr for nr in para_numbers)
|
||||
|
||||
def test_extract_paragraphs_empty_content(self):
|
||||
"""Test keine Paragraphen bei leerem Content."""
|
||||
crawler = LegalCrawler()
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup("<body></body>", "html.parser")
|
||||
|
||||
paragraphs = crawler._extract_paragraphs(soup, "")
|
||||
|
||||
assert paragraphs is None or len(paragraphs) == 0
|
||||
|
||||
def test_extract_paragraphs_no_pattern_match(self):
|
||||
"""Test keine Paragraphen wenn kein Pattern matched."""
|
||||
crawler = LegalCrawler()
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup("<body></body>", "html.parser")
|
||||
|
||||
paragraphs = crawler._extract_paragraphs(soup, "Just some text without paragraphs")
|
||||
|
||||
assert paragraphs is None or len(paragraphs) == 0
|
||||
|
||||
|
||||
class TestCrawlUrl:
|
||||
"""Tests für das URL-Crawling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_url_html_success(self):
|
||||
"""Test erfolgreiches Crawlen einer HTML-URL."""
|
||||
crawler = LegalCrawler()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "text/html; charset=utf-8"}
|
||||
mock_response.text = """
|
||||
<html>
|
||||
<head><title>Schulgesetz NRW</title></head>
|
||||
<body>
|
||||
<main>
|
||||
§ 1 Bildungsauftrag
|
||||
Die Schule hat den Auftrag...
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
mock_response.url = "https://example.com/schulgesetz"
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.return_value = mock_response
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
mock_instance.__aexit__.return_value = None
|
||||
mock_client.return_value = mock_instance
|
||||
|
||||
seed_info = {"name": "SchulG NRW", "state": "NW", "trust_boost": 0.95}
|
||||
doc = await crawler.crawl_url("https://example.com/schulgesetz", seed_info)
|
||||
|
||||
assert doc is not None
|
||||
assert doc.title == "Schulgesetz NRW"
|
||||
assert doc.state == "NW"
|
||||
assert doc.trust_score == 0.95
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_url_404_returns_none(self):
|
||||
"""Test 404 Error gibt None zurück."""
|
||||
crawler = LegalCrawler()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.return_value = mock_response
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
mock_instance.__aexit__.return_value = None
|
||||
mock_client.return_value = mock_instance
|
||||
|
||||
doc = await crawler.crawl_url("https://example.com/notfound", {})
|
||||
|
||||
assert doc is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_url_network_error_returns_none(self):
|
||||
"""Test Netzwerkfehler gibt None zurück."""
|
||||
crawler = LegalCrawler()
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.side_effect = httpx.ConnectError("Network error")
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
mock_instance.__aexit__.return_value = None
|
||||
mock_client.return_value = mock_instance
|
||||
|
||||
doc = await crawler.crawl_url("https://example.com/error", {})
|
||||
|
||||
assert doc is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_url_pdf_returns_none(self):
|
||||
"""Test PDF URLs werden aktuell übersprungen (not implemented)."""
|
||||
crawler = LegalCrawler()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/pdf"}
|
||||
mock_response.content = b"%PDF-1.4..."
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.return_value = mock_response
|
||||
mock_instance.__aenter__.return_value = mock_instance
|
||||
mock_instance.__aexit__.return_value = None
|
||||
mock_client.return_value = mock_instance
|
||||
|
||||
doc = await crawler.crawl_url("https://example.com/doc.pdf", {})
|
||||
|
||||
# PDF extraction ist noch nicht implementiert
|
||||
assert doc is None
|
||||
|
||||
|
||||
class TestGetLegalCrawler:
|
||||
"""Tests für Singleton-Pattern."""
|
||||
|
||||
def test_get_legal_crawler_singleton(self):
|
||||
"""Test dass get_legal_crawler immer dieselbe Instanz zurückgibt."""
|
||||
crawler1 = get_legal_crawler()
|
||||
crawler2 = get_legal_crawler()
|
||||
|
||||
assert crawler1 is crawler2
|
||||
|
||||
def test_get_legal_crawler_returns_crawler(self):
|
||||
"""Test dass get_legal_crawler einen LegalCrawler zurückgibt."""
|
||||
crawler = get_legal_crawler()
|
||||
|
||||
assert isinstance(crawler, LegalCrawler)
|
||||
204
backend/tests/test_llm_gateway/test_models.py
Normal file
204
backend/tests/test_llm_gateway/test_models.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Tests für LLM Gateway Pydantic Models.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from llm_gateway.models.chat import (
|
||||
ChatMessage,
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionChunk,
|
||||
ChatChoice,
|
||||
StreamChoice,
|
||||
ChatChoiceDelta,
|
||||
Usage,
|
||||
ModelInfo,
|
||||
ModelListResponse,
|
||||
RequestMetadata,
|
||||
)
|
||||
|
||||
|
||||
class TestChatMessage:
|
||||
"""Tests für ChatMessage Model."""
|
||||
|
||||
def test_user_message(self):
|
||||
"""Test User Message erstellen."""
|
||||
msg = ChatMessage(role="user", content="Hello")
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "Hello"
|
||||
assert msg.name is None
|
||||
|
||||
def test_assistant_message(self):
|
||||
"""Test Assistant Message erstellen."""
|
||||
msg = ChatMessage(role="assistant", content="Hi there!")
|
||||
assert msg.role == "assistant"
|
||||
assert msg.content == "Hi there!"
|
||||
|
||||
def test_system_message(self):
|
||||
"""Test System Message erstellen."""
|
||||
msg = ChatMessage(role="system", content="You are a helpful assistant.")
|
||||
assert msg.role == "system"
|
||||
|
||||
def test_tool_message(self):
|
||||
"""Test Tool Message erstellen."""
|
||||
msg = ChatMessage(role="tool", content='{"result": "success"}', tool_call_id="call_123")
|
||||
assert msg.role == "tool"
|
||||
assert msg.tool_call_id == "call_123"
|
||||
|
||||
|
||||
class TestChatCompletionRequest:
|
||||
"""Tests für ChatCompletionRequest Model."""
|
||||
|
||||
def test_minimal_request(self):
|
||||
"""Test minimale Request."""
|
||||
req = ChatCompletionRequest(
|
||||
model="breakpilot-teacher-8b",
|
||||
messages=[ChatMessage(role="user", content="Hello")],
|
||||
)
|
||||
assert req.model == "breakpilot-teacher-8b"
|
||||
assert len(req.messages) == 1
|
||||
assert req.stream is False
|
||||
assert req.temperature == 0.7
|
||||
|
||||
def test_full_request(self):
|
||||
"""Test vollständige Request."""
|
||||
req = ChatCompletionRequest(
|
||||
model="breakpilot-teacher-70b",
|
||||
messages=[
|
||||
ChatMessage(role="system", content="Du bist ein Assistent."),
|
||||
ChatMessage(role="user", content="Schreibe einen Brief."),
|
||||
],
|
||||
stream=True,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
metadata=RequestMetadata(playbook_id="pb_elternbrief"),
|
||||
)
|
||||
assert req.stream is True
|
||||
assert req.temperature == 0.5
|
||||
assert req.max_tokens == 1000
|
||||
assert req.metadata.playbook_id == "pb_elternbrief"
|
||||
|
||||
def test_temperature_bounds(self):
|
||||
"""Test Temperature Grenzen."""
|
||||
# Gültige Werte
|
||||
req = ChatCompletionRequest(
|
||||
model="test",
|
||||
messages=[ChatMessage(role="user", content="test")],
|
||||
temperature=0.0,
|
||||
)
|
||||
assert req.temperature == 0.0
|
||||
|
||||
req = ChatCompletionRequest(
|
||||
model="test",
|
||||
messages=[ChatMessage(role="user", content="test")],
|
||||
temperature=2.0,
|
||||
)
|
||||
assert req.temperature == 2.0
|
||||
|
||||
# Ungültige Werte
|
||||
with pytest.raises(ValueError):
|
||||
ChatCompletionRequest(
|
||||
model="test",
|
||||
messages=[ChatMessage(role="user", content="test")],
|
||||
temperature=2.5,
|
||||
)
|
||||
|
||||
|
||||
class TestChatCompletionResponse:
|
||||
"""Tests für ChatCompletionResponse Model."""
|
||||
|
||||
def test_response_creation(self):
|
||||
"""Test Response erstellen."""
|
||||
response = ChatCompletionResponse(
|
||||
model="breakpilot-teacher-8b",
|
||||
choices=[
|
||||
ChatChoice(
|
||||
index=0,
|
||||
message=ChatMessage(role="assistant", content="Hello!"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||
)
|
||||
assert response.object == "chat.completion"
|
||||
assert response.model == "breakpilot-teacher-8b"
|
||||
assert len(response.choices) == 1
|
||||
assert response.choices[0].message.content == "Hello!"
|
||||
assert response.usage.total_tokens == 15
|
||||
|
||||
def test_response_has_id(self):
|
||||
"""Test dass Response eine ID hat."""
|
||||
response = ChatCompletionResponse(
|
||||
model="test",
|
||||
choices=[
|
||||
ChatChoice(
|
||||
message=ChatMessage(role="assistant", content="test"),
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.id.startswith("chatcmpl-")
|
||||
assert len(response.id) > 10
|
||||
|
||||
|
||||
class TestChatCompletionChunk:
|
||||
"""Tests für Streaming Chunks."""
|
||||
|
||||
def test_chunk_creation(self):
|
||||
"""Test Chunk erstellen."""
|
||||
chunk = ChatCompletionChunk(
|
||||
model="breakpilot-teacher-8b",
|
||||
choices=[
|
||||
StreamChoice(
|
||||
index=0,
|
||||
delta=ChatChoiceDelta(content="Hello"),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert chunk.object == "chat.completion.chunk"
|
||||
assert chunk.choices[0].delta.content == "Hello"
|
||||
|
||||
def test_final_chunk(self):
|
||||
"""Test Final Chunk mit finish_reason."""
|
||||
chunk = ChatCompletionChunk(
|
||||
model="test",
|
||||
choices=[
|
||||
StreamChoice(
|
||||
index=0,
|
||||
delta=ChatChoiceDelta(),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
assert chunk.choices[0].finish_reason == "stop"
|
||||
|
||||
|
||||
class TestModelInfo:
|
||||
"""Tests für ModelInfo."""
|
||||
|
||||
def test_model_info(self):
|
||||
"""Test ModelInfo erstellen."""
|
||||
model = ModelInfo(
|
||||
id="breakpilot-teacher-8b",
|
||||
owned_by="breakpilot",
|
||||
description="Test model",
|
||||
context_length=8192,
|
||||
)
|
||||
assert model.id == "breakpilot-teacher-8b"
|
||||
assert model.object == "model"
|
||||
assert model.context_length == 8192
|
||||
|
||||
|
||||
class TestModelListResponse:
|
||||
"""Tests für ModelListResponse."""
|
||||
|
||||
def test_model_list(self):
|
||||
"""Test Model List erstellen."""
|
||||
response = ModelListResponse(
|
||||
data=[
|
||||
ModelInfo(id="model-1", owned_by="test"),
|
||||
ModelInfo(id="model-2", owned_by="test"),
|
||||
]
|
||||
)
|
||||
assert response.object == "list"
|
||||
assert len(response.data) == 2
|
||||
296
backend/tests/test_llm_gateway/test_pii_detector.py
Normal file
296
backend/tests/test_llm_gateway/test_pii_detector.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Tests für PII Detector Service.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from llm_gateway.services.pii_detector import (
|
||||
PIIDetector,
|
||||
PIIType,
|
||||
PIIMatch,
|
||||
RedactionResult,
|
||||
get_pii_detector,
|
||||
)
|
||||
|
||||
|
||||
class TestPIIDetectorPatterns:
|
||||
"""Tests für PII-Erkennung."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.detector = PIIDetector()
|
||||
|
||||
def test_detect_email(self):
|
||||
"""Test E-Mail-Erkennung."""
|
||||
text = "Kontakt: max.mustermann@example.com für Rückfragen"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 1
|
||||
assert matches[0].type == PIIType.EMAIL
|
||||
assert matches[0].value == "max.mustermann@example.com"
|
||||
|
||||
def test_detect_multiple_emails(self):
|
||||
"""Test mehrerer E-Mail-Adressen."""
|
||||
text = "Von: a@b.de An: c@d.com CC: e@f.org"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 3
|
||||
assert all(m.type == PIIType.EMAIL for m in matches)
|
||||
|
||||
def test_detect_german_phone(self):
|
||||
"""Test deutsche Telefonnummer."""
|
||||
text = "Erreichbar unter 089 12345678"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 1
|
||||
assert matches[0].type == PIIType.PHONE
|
||||
|
||||
def test_detect_phone_with_country_code(self):
|
||||
"""Test Telefonnummer mit Landesvorwahl."""
|
||||
text = "Tel: +49 30 1234567"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 1
|
||||
assert matches[0].type == PIIType.PHONE
|
||||
|
||||
def test_detect_iban(self):
|
||||
"""Test IBAN-Erkennung."""
|
||||
text = "IBAN: DE89370400440532013000"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
# IBAN sollte erkannt werden (evtl. auch als Telefon, aber IBAN hat Priorität)
|
||||
iban_matches = [m for m in matches if m.type == PIIType.IBAN]
|
||||
assert len(iban_matches) >= 1
|
||||
|
||||
def test_detect_iban_with_spaces(self):
|
||||
"""Test IBAN mit Leerzeichen."""
|
||||
text = "Konto: DE89 3704 0044 0532 0130 00"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
# Bei überlappenden Matches gewinnt IBAN wegen höherer Priorität
|
||||
iban_matches = [m for m in matches if m.type == PIIType.IBAN]
|
||||
assert len(iban_matches) >= 1
|
||||
|
||||
def test_detect_credit_card_visa(self):
|
||||
"""Test Visa Kreditkarte."""
|
||||
text = "Karte: 4111 1111 1111 1111"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 1
|
||||
assert matches[0].type == PIIType.CREDIT_CARD
|
||||
|
||||
def test_detect_credit_card_mastercard(self):
|
||||
"""Test Mastercard."""
|
||||
text = "MC: 5500 0000 0000 0004"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
# Kreditkarte hat höhere Priorität als Telefon
|
||||
cc_matches = [m for m in matches if m.type == PIIType.CREDIT_CARD]
|
||||
assert len(cc_matches) >= 1
|
||||
|
||||
def test_detect_ip_address(self):
|
||||
"""Test IP-Adresse."""
|
||||
text = "Server IP: 192.168.1.100"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 1
|
||||
assert matches[0].type == PIIType.IP_ADDRESS
|
||||
|
||||
def test_detect_date_of_birth(self):
|
||||
"""Test Geburtsdatum."""
|
||||
text = "Geboren am 15.03.1985"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 1
|
||||
assert matches[0].type == PIIType.DATE_OF_BIRTH
|
||||
|
||||
def test_detect_date_single_digit(self):
|
||||
"""Test Datum mit einstelligem Tag/Monat."""
|
||||
text = "DOB: 1.5.1990"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
assert len(matches) == 1
|
||||
assert matches[0].type == PIIType.DATE_OF_BIRTH
|
||||
|
||||
def test_no_false_positive_year(self):
|
||||
"""Test dass Jahre allein nicht erkannt werden."""
|
||||
text = "Im Jahr 2024 wurde das System eingeführt"
|
||||
matches = self.detector.detect(text)
|
||||
|
||||
# Sollte kein DOB sein
|
||||
assert all(m.type != PIIType.DATE_OF_BIRTH for m in matches)
|
||||
|
||||
|
||||
class TestPIIDetectorRedaction:
|
||||
"""Tests für PII-Redaktion."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.detector = PIIDetector()
|
||||
|
||||
def test_redact_email(self):
|
||||
"""Test E-Mail Redaktion."""
|
||||
text = "Mail an test@example.com senden"
|
||||
result = self.detector.redact(text)
|
||||
|
||||
assert result.pii_found is True
|
||||
assert "test@example.com" not in result.redacted_text
|
||||
assert "[EMAIL_REDACTED]" in result.redacted_text
|
||||
assert result.original_text == text
|
||||
|
||||
def test_redact_multiple_pii(self):
|
||||
"""Test Redaktion mehrerer PII-Typen."""
|
||||
text = "Kontakt: max@test.de, Tel: 089 123456"
|
||||
result = self.detector.redact(text)
|
||||
|
||||
assert result.pii_found is True
|
||||
assert len(result.matches) == 2
|
||||
assert "[EMAIL_REDACTED]" in result.redacted_text
|
||||
assert "[PHONE_REDACTED]" in result.redacted_text
|
||||
|
||||
def test_redact_preserves_structure(self):
|
||||
"""Test dass Textstruktur erhalten bleibt."""
|
||||
text = "Von: a@b.de\nAn: c@d.de"
|
||||
result = self.detector.redact(text)
|
||||
|
||||
assert "\n" in result.redacted_text
|
||||
assert "Von:" in result.redacted_text
|
||||
assert "An:" in result.redacted_text
|
||||
|
||||
def test_no_pii_returns_original(self):
|
||||
"""Test ohne PII gibt Original zurück."""
|
||||
text = "Keine personenbezogenen Daten hier"
|
||||
result = self.detector.redact(text)
|
||||
|
||||
assert result.pii_found is False
|
||||
assert result.redacted_text == text
|
||||
assert len(result.matches) == 0
|
||||
|
||||
|
||||
class TestPIIDetectorContainsPII:
|
||||
"""Tests für schnelle PII-Prüfung."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.detector = PIIDetector()
|
||||
|
||||
def test_contains_pii_with_email(self):
|
||||
"""Test contains_pii mit E-Mail."""
|
||||
assert self.detector.contains_pii("test@example.com") is True
|
||||
|
||||
def test_contains_pii_with_phone(self):
|
||||
"""Test contains_pii mit Telefon."""
|
||||
assert self.detector.contains_pii("+49 89 123456") is True
|
||||
|
||||
def test_contains_pii_without_pii(self):
|
||||
"""Test contains_pii ohne PII."""
|
||||
assert self.detector.contains_pii("Schulrecht Bayern") is False
|
||||
|
||||
|
||||
class TestPIIDetectorConfiguration:
|
||||
"""Tests für Detector-Konfiguration."""
|
||||
|
||||
def test_custom_enabled_types(self):
|
||||
"""Test mit eingeschränkten PII-Typen."""
|
||||
detector = PIIDetector(enabled_types=[PIIType.EMAIL])
|
||||
|
||||
# E-Mail wird erkannt
|
||||
assert len(detector.detect("test@example.com")) == 1
|
||||
|
||||
# Telefon wird nicht erkannt
|
||||
assert len(detector.detect("+49 89 123456")) == 0
|
||||
|
||||
def test_empty_enabled_types(self):
|
||||
"""Test mit leerer Typen-Liste."""
|
||||
detector = PIIDetector(enabled_types=[])
|
||||
|
||||
# Nichts wird erkannt
|
||||
assert len(detector.detect("test@example.com +49 89 123456")) == 0
|
||||
|
||||
|
||||
class TestPIIMatch:
|
||||
"""Tests für PIIMatch Dataclass."""
|
||||
|
||||
def test_pii_match_creation(self):
|
||||
"""Test PIIMatch erstellen."""
|
||||
match = PIIMatch(
|
||||
type=PIIType.EMAIL,
|
||||
value="test@example.com",
|
||||
start=0,
|
||||
end=16,
|
||||
replacement="[EMAIL_REDACTED]",
|
||||
)
|
||||
|
||||
assert match.type == PIIType.EMAIL
|
||||
assert match.value == "test@example.com"
|
||||
assert match.start == 0
|
||||
assert match.end == 16
|
||||
|
||||
|
||||
class TestRedactionResult:
|
||||
"""Tests für RedactionResult Dataclass."""
|
||||
|
||||
def test_redaction_result_creation(self):
|
||||
"""Test RedactionResult erstellen."""
|
||||
result = RedactionResult(
|
||||
original_text="test@example.com",
|
||||
redacted_text="[EMAIL_REDACTED]",
|
||||
matches=[],
|
||||
pii_found=True,
|
||||
)
|
||||
|
||||
assert result.pii_found is True
|
||||
assert result.original_text == "test@example.com"
|
||||
assert result.redacted_text == "[EMAIL_REDACTED]"
|
||||
|
||||
|
||||
class TestGetPIIDetectorSingleton:
|
||||
"""Tests für Singleton Pattern."""
|
||||
|
||||
def test_singleton_returns_same_instance(self):
|
||||
"""Test dass get_pii_detector Singleton zurückgibt."""
|
||||
detector1 = get_pii_detector()
|
||||
detector2 = get_pii_detector()
|
||||
assert detector1 is detector2
|
||||
|
||||
|
||||
class TestPIIRealWorldExamples:
|
||||
"""Tests mit realistischen Beispielen."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.detector = PIIDetector()
|
||||
|
||||
def test_school_query_without_pii(self):
|
||||
"""Test Schulanfrage ohne PII."""
|
||||
query = "Welche Regeln gelten für Datenschutz an Schulen in Bayern?"
|
||||
result = self.detector.redact(query)
|
||||
|
||||
assert result.pii_found is False
|
||||
assert result.redacted_text == query
|
||||
|
||||
def test_school_query_with_email(self):
|
||||
"""Test Schulanfrage mit E-Mail."""
|
||||
query = "Wie kontaktiere ich lehrer.mueller@schule.de wegen Datenschutz?"
|
||||
result = self.detector.redact(query)
|
||||
|
||||
assert result.pii_found is True
|
||||
assert "lehrer.mueller@schule.de" not in result.redacted_text
|
||||
assert "[EMAIL_REDACTED]" in result.redacted_text
|
||||
|
||||
def test_parent_letter_with_multiple_pii(self):
|
||||
"""Test Elternbrief mit mehreren PII."""
|
||||
text = """
|
||||
Sehr geehrte Familie Müller,
|
||||
bitte rufen Sie unter 089 12345678 an oder
|
||||
schreiben Sie an eltern@schule.de.
|
||||
IBAN für Klassenfahrt: DE89370400440532013000
|
||||
"""
|
||||
result = self.detector.redact(text)
|
||||
|
||||
assert result.pii_found is True
|
||||
# Mindestens 3 Matches (Telefon, E-Mail, IBAN)
|
||||
# Überlappende werden gefiltert (IBAN hat Priorität über Telefon)
|
||||
assert len(result.matches) >= 3
|
||||
assert "[PHONE_REDACTED]" in result.redacted_text
|
||||
assert "[EMAIL_REDACTED]" in result.redacted_text
|
||||
assert "[IBAN_REDACTED]" in result.redacted_text
|
||||
199
backend/tests/test_llm_gateway/test_playbook_service.py
Normal file
199
backend/tests/test_llm_gateway/test_playbook_service.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Tests für Playbook Service.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from llm_gateway.services.playbook_service import (
|
||||
PlaybookService,
|
||||
Playbook,
|
||||
get_playbook_service,
|
||||
)
|
||||
|
||||
|
||||
class TestPlaybookService:
|
||||
"""Tests für PlaybookService."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.service = PlaybookService()
|
||||
|
||||
def test_list_playbooks_returns_default_playbooks(self):
|
||||
"""Test dass Default-Playbooks geladen werden."""
|
||||
playbooks = self.service.list_playbooks()
|
||||
assert len(playbooks) > 0
|
||||
# Prüfe dass bekannte Playbooks existieren
|
||||
ids = [p.id for p in playbooks]
|
||||
assert "pb_default" in ids
|
||||
assert "pb_elternbrief" in ids
|
||||
assert "pb_arbeitsblatt" in ids
|
||||
|
||||
def test_list_playbooks_filter_by_status(self):
|
||||
"""Test Status-Filter."""
|
||||
# Alle Default-Playbooks sind published
|
||||
published = self.service.list_playbooks(status="published")
|
||||
assert len(published) > 0
|
||||
|
||||
# Keine Draft-Playbooks
|
||||
drafts = self.service.list_playbooks(status="draft")
|
||||
assert len(drafts) == 0
|
||||
|
||||
def test_get_playbook_existing(self):
|
||||
"""Test Playbook abrufen."""
|
||||
playbook = self.service.get_playbook("pb_default")
|
||||
assert playbook is not None
|
||||
assert playbook.id == "pb_default"
|
||||
assert playbook.name == "Standard-Assistent"
|
||||
assert len(playbook.system_prompt) > 0
|
||||
|
||||
def test_get_playbook_not_found(self):
|
||||
"""Test nicht existierendes Playbook."""
|
||||
playbook = self.service.get_playbook("non_existent")
|
||||
assert playbook is None
|
||||
|
||||
def test_get_system_prompt(self):
|
||||
"""Test System Prompt abrufen."""
|
||||
prompt = self.service.get_system_prompt("pb_elternbrief")
|
||||
assert prompt is not None
|
||||
assert "Elternbrief" in prompt or "Elternkommunikation" in prompt
|
||||
|
||||
def test_get_system_prompt_not_found(self):
|
||||
"""Test System Prompt für nicht existierendes Playbook."""
|
||||
prompt = self.service.get_system_prompt("non_existent")
|
||||
assert prompt is None
|
||||
|
||||
def test_create_playbook(self):
|
||||
"""Test neues Playbook erstellen."""
|
||||
new_playbook = Playbook(
|
||||
id="pb_test_new",
|
||||
name="Test Playbook",
|
||||
description="Ein Test Playbook",
|
||||
system_prompt="Du bist ein Test-Assistent.",
|
||||
prompt_version="1.0.0",
|
||||
)
|
||||
created = self.service.create_playbook(new_playbook)
|
||||
assert created.id == "pb_test_new"
|
||||
|
||||
# Prüfe dass es abrufbar ist
|
||||
retrieved = self.service.get_playbook("pb_test_new")
|
||||
assert retrieved is not None
|
||||
assert retrieved.name == "Test Playbook"
|
||||
|
||||
def test_create_playbook_duplicate_id(self):
|
||||
"""Test Playbook mit existierender ID erstellen."""
|
||||
duplicate = Playbook(
|
||||
id="pb_default", # Existiert bereits
|
||||
name="Duplicate",
|
||||
description="Test",
|
||||
system_prompt="Test",
|
||||
prompt_version="1.0.0",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
self.service.create_playbook(duplicate)
|
||||
|
||||
def test_update_playbook(self):
|
||||
"""Test Playbook aktualisieren."""
|
||||
original = self.service.get_playbook("pb_default")
|
||||
original_name = original.name
|
||||
|
||||
updated = self.service.update_playbook(
|
||||
"pb_default",
|
||||
name="Aktualisierter Name",
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.name == "Aktualisierter Name"
|
||||
|
||||
# Reset
|
||||
self.service.update_playbook("pb_default", name=original_name)
|
||||
|
||||
def test_update_playbook_not_found(self):
|
||||
"""Test nicht existierendes Playbook aktualisieren."""
|
||||
result = self.service.update_playbook("non_existent", name="Test")
|
||||
assert result is None
|
||||
|
||||
def test_delete_playbook(self):
|
||||
"""Test Playbook löschen."""
|
||||
# Erst erstellen
|
||||
new_playbook = Playbook(
|
||||
id="pb_to_delete",
|
||||
name="To Delete",
|
||||
description="Test",
|
||||
system_prompt="Test",
|
||||
prompt_version="1.0.0",
|
||||
)
|
||||
self.service.create_playbook(new_playbook)
|
||||
|
||||
# Dann löschen
|
||||
result = self.service.delete_playbook("pb_to_delete")
|
||||
assert result is True
|
||||
|
||||
# Prüfen dass gelöscht
|
||||
assert self.service.get_playbook("pb_to_delete") is None
|
||||
|
||||
def test_delete_playbook_not_found(self):
|
||||
"""Test nicht existierendes Playbook löschen."""
|
||||
result = self.service.delete_playbook("non_existent")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPlaybookContent:
|
||||
"""Tests für Playbook-Inhalte."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
self.service = PlaybookService()
|
||||
|
||||
def test_elternbrief_playbook_has_guidelines(self):
|
||||
"""Test dass Elternbrief-Playbook Richtlinien enthält."""
|
||||
playbook = self.service.get_playbook("pb_elternbrief")
|
||||
assert playbook is not None
|
||||
prompt = playbook.system_prompt.lower()
|
||||
# Sollte wichtige Richtlinien enthalten
|
||||
assert "ton" in prompt or "sprache" in prompt
|
||||
assert "brief" in prompt or "eltern" in prompt
|
||||
|
||||
def test_rechtlich_playbook_has_disclaimer(self):
|
||||
"""Test dass Rechtlich-Playbook Disclaimer enthält."""
|
||||
playbook = self.service.get_playbook("pb_rechtlich")
|
||||
assert playbook is not None
|
||||
prompt = playbook.system_prompt.lower()
|
||||
# Sollte Hinweis auf keine Rechtsberatung enthalten
|
||||
assert "rechtsberatung" in prompt or "fachanwalt" in prompt
|
||||
|
||||
def test_foerderplan_playbook_mentions_privacy(self):
|
||||
"""Test dass Förderplan-Playbook Datenschutz erwähnt."""
|
||||
playbook = self.service.get_playbook("pb_foerderplan")
|
||||
assert playbook is not None
|
||||
prompt = playbook.system_prompt.lower()
|
||||
# Sollte sensible Daten erwähnen
|
||||
assert "sensib" in prompt or "vertraulich" in prompt or "daten" in prompt
|
||||
|
||||
def test_all_playbooks_have_required_fields(self):
|
||||
"""Test dass alle Playbooks Pflichtfelder haben."""
|
||||
playbooks = self.service.list_playbooks(status=None)
|
||||
for playbook in playbooks:
|
||||
assert playbook.id is not None
|
||||
assert len(playbook.id) > 0
|
||||
assert playbook.name is not None
|
||||
assert len(playbook.name) > 0
|
||||
assert playbook.system_prompt is not None
|
||||
assert len(playbook.system_prompt) > 0
|
||||
assert playbook.prompt_version is not None
|
||||
|
||||
def test_playbooks_have_tool_policy(self):
|
||||
"""Test dass Playbooks Tool Policy haben."""
|
||||
playbooks = self.service.list_playbooks()
|
||||
for playbook in playbooks:
|
||||
assert hasattr(playbook, "tool_policy")
|
||||
# no_pii_in_output sollte standardmäßig true sein
|
||||
assert playbook.tool_policy.get("no_pii_in_output") is True
|
||||
|
||||
|
||||
class TestGetPlaybookServiceSingleton:
|
||||
"""Tests für Singleton Pattern."""
|
||||
|
||||
def test_singleton_returns_same_instance(self):
|
||||
"""Test dass get_playbook_service Singleton zurückgibt."""
|
||||
service1 = get_playbook_service()
|
||||
service2 = get_playbook_service()
|
||||
assert service1 is service2
|
||||
369
backend/tests/test_llm_gateway/test_tool_gateway.py
Normal file
369
backend/tests/test_llm_gateway/test_tool_gateway.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
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
|
||||
366
backend/tests/test_llm_gateway/test_tools_routes.py
Normal file
366
backend/tests/test_llm_gateway/test_tools_routes.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Integration Tests für Tools Routes.
|
||||
|
||||
Testet die API-Endpoints /llm/tools/search und /llm/tools/health.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
|
||||
from llm_gateway.routes.tools import router
|
||||
from llm_gateway.middleware.auth import verify_api_key
|
||||
from llm_gateway.services.tool_gateway import (
|
||||
ToolGateway,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
TavilyError,
|
||||
ToolGatewayError,
|
||||
get_tool_gateway,
|
||||
)
|
||||
|
||||
|
||||
# Test App erstellen mit Auth-Override
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/tools")
|
||||
|
||||
|
||||
# Mock für Auth-Dependency
|
||||
def mock_verify_api_key():
|
||||
return "test-user"
|
||||
|
||||
|
||||
class TestSearchEndpoint:
|
||||
"""Tests für POST /tools/search."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
# Auth-Dependency überschreiben für Tests
|
||||
app.dependency_overrides[verify_api_key] = mock_verify_api_key
|
||||
self.client = TestClient(app)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Cleanup nach jedem Test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_search_requires_auth(self):
|
||||
"""Test dass Auth erforderlich ist."""
|
||||
# Auth-Override entfernen für diesen Test
|
||||
app.dependency_overrides.clear()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/tools/search",
|
||||
json={"query": "test"},
|
||||
)
|
||||
# Ohne API-Key sollte 401/403 kommen
|
||||
assert response.status_code in [401, 403]
|
||||
|
||||
def test_search_invalid_query_too_short(self):
|
||||
"""Test Validierung: Query zu kurz."""
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": ""},
|
||||
)
|
||||
assert response.status_code == 422 # Validation Error
|
||||
|
||||
def test_search_invalid_max_results(self):
|
||||
"""Test Validierung: max_results außerhalb Grenzen."""
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "test", "max_results": 100}, # > 20
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_search_success(self):
|
||||
"""Test erfolgreiche Suche."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.search = AsyncMock(return_value=SearchResponse(
|
||||
query="Datenschutz Schule",
|
||||
results=[
|
||||
SearchResult(
|
||||
title="Datenschutz an Schulen",
|
||||
url="https://example.com",
|
||||
content="Informationen...",
|
||||
score=0.9,
|
||||
)
|
||||
],
|
||||
answer="Zusammenfassung",
|
||||
pii_detected=False,
|
||||
response_time_ms=1500,
|
||||
))
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "Datenschutz Schule"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["query"] == "Datenschutz Schule"
|
||||
assert len(data["results"]) == 1
|
||||
assert data["results"][0]["title"] == "Datenschutz an Schulen"
|
||||
assert data["pii_detected"] is False
|
||||
|
||||
def test_search_with_pii_redaction(self):
|
||||
"""Test Suche mit PII-Erkennung."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.search = AsyncMock(return_value=SearchResponse(
|
||||
query="Kontakt test@example.com",
|
||||
redacted_query="Kontakt [EMAIL_REDACTED]",
|
||||
results=[],
|
||||
pii_detected=True,
|
||||
pii_types=["email"],
|
||||
response_time_ms=1000,
|
||||
))
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "Kontakt test@example.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["pii_detected"] is True
|
||||
assert "email" in data["pii_types"]
|
||||
assert data["redacted_query"] == "Kontakt [EMAIL_REDACTED]"
|
||||
|
||||
def test_search_with_domain_filters(self):
|
||||
"""Test Suche mit Domain-Filtern."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.search = AsyncMock(return_value=SearchResponse(
|
||||
query="test",
|
||||
results=[],
|
||||
pii_detected=False,
|
||||
response_time_ms=500,
|
||||
))
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={
|
||||
"query": "test",
|
||||
"include_domains": ["bayern.de"],
|
||||
"exclude_domains": ["wikipedia.org"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Prüfen dass Filter an Gateway übergeben wurden
|
||||
mock_gateway.search.assert_called_once()
|
||||
call_kwargs = mock_gateway.search.call_args.kwargs
|
||||
assert call_kwargs["include_domains"] == ["bayern.de"]
|
||||
assert call_kwargs["exclude_domains"] == ["wikipedia.org"]
|
||||
|
||||
def test_search_gateway_error(self):
|
||||
"""Test Fehlerbehandlung bei Gateway-Fehler."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.search = AsyncMock(side_effect=ToolGatewayError("Not configured"))
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "unavailable" in response.json()["detail"].lower()
|
||||
|
||||
def test_search_tavily_error(self):
|
||||
"""Test Fehlerbehandlung bei Tavily-Fehler."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.search = AsyncMock(side_effect=TavilyError("Rate limit"))
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert "search service error" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Tests für GET /tools/health."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
app.dependency_overrides[verify_api_key] = mock_verify_api_key
|
||||
self.client = TestClient(app)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Cleanup nach jedem Test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_health_requires_auth(self):
|
||||
"""Test dass Auth erforderlich ist."""
|
||||
app.dependency_overrides.clear()
|
||||
client = TestClient(app)
|
||||
response = client.get("/tools/health")
|
||||
assert response.status_code in [401, 403]
|
||||
|
||||
def test_health_success(self):
|
||||
"""Test erfolgreicher Health Check."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.health_check = AsyncMock(return_value={
|
||||
"tavily": {
|
||||
"configured": True,
|
||||
"healthy": True,
|
||||
"response_time_ms": 1500,
|
||||
},
|
||||
"pii_redaction": {
|
||||
"enabled": True,
|
||||
},
|
||||
})
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.get("/tools/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tavily"]["configured"] is True
|
||||
assert data["tavily"]["healthy"] is True
|
||||
assert data["pii_redaction"]["enabled"] is True
|
||||
|
||||
def test_health_tavily_not_configured(self):
|
||||
"""Test Health Check ohne Tavily-Konfiguration."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.health_check = AsyncMock(return_value={
|
||||
"tavily": {
|
||||
"configured": False,
|
||||
"healthy": False,
|
||||
},
|
||||
"pii_redaction": {
|
||||
"enabled": True,
|
||||
},
|
||||
})
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.get("/tools/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tavily"]["configured"] is False
|
||||
|
||||
|
||||
class TestSearchRequestValidation:
|
||||
"""Tests für Request-Validierung."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
app.dependency_overrides[verify_api_key] = mock_verify_api_key
|
||||
self.client = TestClient(app)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Cleanup nach jedem Test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_query_max_length(self):
|
||||
"""Test Query max length Validierung."""
|
||||
# Query mit > 1000 Zeichen
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "x" * 1001},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_search_depth_enum(self):
|
||||
"""Test search_depth Enum Validierung."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.search = AsyncMock(return_value=SearchResponse(
|
||||
query="test",
|
||||
results=[],
|
||||
pii_detected=False,
|
||||
response_time_ms=100,
|
||||
))
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
# Gültiger Wert
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "test", "search_depth": "advanced"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_search_depth_invalid(self):
|
||||
"""Test ungültiger search_depth Wert."""
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "test", "search_depth": "invalid"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestSearchResponseFormat:
|
||||
"""Tests für Response-Format."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup für jeden Test."""
|
||||
app.dependency_overrides[verify_api_key] = mock_verify_api_key
|
||||
self.client = TestClient(app)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Cleanup nach jedem Test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_has_all_fields(self):
|
||||
"""Test dass Response alle erforderlichen Felder hat."""
|
||||
mock_gateway = MagicMock(spec=ToolGateway)
|
||||
mock_gateway.search = AsyncMock(return_value=SearchResponse(
|
||||
query="test query",
|
||||
redacted_query=None,
|
||||
results=[
|
||||
SearchResult(
|
||||
title="Result 1",
|
||||
url="https://example.com/1",
|
||||
content="Content 1",
|
||||
score=0.95,
|
||||
published_date="2024-01-15",
|
||||
),
|
||||
],
|
||||
answer="AI Summary",
|
||||
pii_detected=False,
|
||||
pii_types=[],
|
||||
response_time_ms=2000,
|
||||
))
|
||||
|
||||
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
|
||||
|
||||
response = self.client.post(
|
||||
"/tools/search",
|
||||
json={"query": "test query"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Pflichtfelder
|
||||
assert "query" in data
|
||||
assert "results" in data
|
||||
assert "pii_detected" in data
|
||||
assert "pii_types" in data
|
||||
assert "response_time_ms" in data
|
||||
|
||||
# Optionale Felder
|
||||
assert "redacted_query" in data
|
||||
assert "answer" in data
|
||||
|
||||
# Result-Felder
|
||||
result = data["results"][0]
|
||||
assert "title" in result
|
||||
assert "url" in result
|
||||
assert "content" in result
|
||||
assert "score" in result
|
||||
assert "published_date" in result
|
||||
Reference in New Issue
Block a user