feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
tests/test_providers/__init__.py
Normal file
1
tests/test_providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for translation providers package."""
|
||||
182
tests/test_providers/test_base.py
Normal file
182
tests/test_providers/test_base.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Tests for the TranslationProvider base class and schemas.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from abc import ABC
|
||||
|
||||
from services.providers.base import TranslationProvider
|
||||
from services.providers.schemas import (
|
||||
TranslationRequest,
|
||||
TranslationResponse,
|
||||
BatchTranslationRequest,
|
||||
BatchTranslationResponse,
|
||||
ProviderHealthStatus,
|
||||
)
|
||||
|
||||
|
||||
class ConcreteTranslationProvider(TranslationProvider):
|
||||
"""Concrete implementation for testing abstract base class."""
|
||||
|
||||
def __init__(self, name: str = "test", available: bool = True):
|
||||
self._name = name
|
||||
self._available = available
|
||||
|
||||
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
|
||||
return TranslationResponse(
|
||||
translated_text=f"[{request.target_language}] {request.text}",
|
||||
provider_name=self._name,
|
||||
from_cache=False,
|
||||
)
|
||||
|
||||
def get_name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
|
||||
class TestSchemas:
|
||||
"""Tests for Pydantic schema models."""
|
||||
|
||||
def test_translation_request_defaults(self):
|
||||
"""Test TranslationRequest with default values."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
assert request.text == "Hello"
|
||||
assert request.target_language == "fr"
|
||||
assert request.source_language == "auto"
|
||||
|
||||
def test_translation_request_custom_source(self):
|
||||
"""Test TranslationRequest with custom source language."""
|
||||
request = TranslationRequest(
|
||||
text="Hello", target_language="fr", source_language="en"
|
||||
)
|
||||
assert request.source_language == "en"
|
||||
|
||||
def test_translation_response_defaults(self):
|
||||
"""Test TranslationResponse with default values."""
|
||||
response = TranslationResponse(translated_text="Bonjour", provider_name="test")
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "test"
|
||||
assert response.from_cache is False
|
||||
assert response.source_language is None
|
||||
assert response.error is None
|
||||
assert response.success is True
|
||||
|
||||
def test_translation_response_with_error(self):
|
||||
"""Test TranslationResponse with error."""
|
||||
response = TranslationResponse(
|
||||
translated_text="Hello",
|
||||
provider_name="test",
|
||||
error="API Error",
|
||||
)
|
||||
assert response.error == "API Error"
|
||||
assert response.success is False
|
||||
|
||||
def test_translation_request_invalid_language(self):
|
||||
"""Test TranslationRequest rejects invalid language codes."""
|
||||
with pytest.raises(ValueError):
|
||||
TranslationRequest(text="Hello", target_language="invalid123")
|
||||
|
||||
def test_batch_translation_request(self):
|
||||
"""Test BatchTranslationRequest."""
|
||||
request = BatchTranslationRequest(
|
||||
texts=["Hello", "World"], target_language="fr"
|
||||
)
|
||||
assert len(request.texts) == 2
|
||||
assert request.source_language == "auto"
|
||||
|
||||
def test_batch_translation_response(self):
|
||||
"""Test BatchTranslationResponse."""
|
||||
response = BatchTranslationResponse(
|
||||
translated_texts=["Bonjour", "Monde"],
|
||||
provider_name="test",
|
||||
from_cache_count=1,
|
||||
)
|
||||
assert len(response.translated_texts) == 2
|
||||
assert response.from_cache_count == 1
|
||||
|
||||
def test_provider_health_status(self):
|
||||
"""Test ProviderHealthStatus."""
|
||||
status = ProviderHealthStatus(name="test", available=True, latency_ms=50.5)
|
||||
assert status.name == "test"
|
||||
assert status.available is True
|
||||
assert status.latency_ms == 50.5
|
||||
assert status.error is None
|
||||
|
||||
def test_provider_health_status_with_error(self):
|
||||
"""Test ProviderHealthStatus with error."""
|
||||
status = ProviderHealthStatus(
|
||||
name="test", available=False, error="Connection refused"
|
||||
)
|
||||
assert status.available is False
|
||||
assert status.error == "Connection refused"
|
||||
|
||||
|
||||
class TestTranslationProviderBaseClass:
|
||||
"""Tests for the TranslationProvider abstract base class."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
"""Test that TranslationProvider cannot be instantiated directly."""
|
||||
assert issubclass(TranslationProvider, ABC)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
TranslationProvider()
|
||||
|
||||
def test_concrete_implementation_works(self):
|
||||
"""Test that a concrete implementation can be instantiated."""
|
||||
provider = ConcreteTranslationProvider()
|
||||
assert provider.get_name() == "test"
|
||||
assert provider.is_available() is True
|
||||
|
||||
def test_translate_text(self):
|
||||
"""Test translate_text method."""
|
||||
provider = ConcreteTranslationProvider()
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "[fr] Hello"
|
||||
assert response.provider_name == "test"
|
||||
|
||||
def test_translate_batch_default_implementation(self):
|
||||
"""Test default translate_batch implementation."""
|
||||
provider = ConcreteTranslationProvider()
|
||||
requests = [
|
||||
TranslationRequest(text="Hello", target_language="fr"),
|
||||
TranslationRequest(text="World", target_language="fr"),
|
||||
]
|
||||
responses = provider.translate_batch(requests)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert responses[0].translated_text == "[fr] Hello"
|
||||
assert responses[1].translated_text == "[fr] World"
|
||||
|
||||
def test_health_check_available(self):
|
||||
"""Test health_check for available provider."""
|
||||
provider = ConcreteTranslationProvider(available=True)
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "test"
|
||||
assert status.available is True
|
||||
assert status.error is None
|
||||
assert status.latency_ms is not None
|
||||
|
||||
def test_health_check_unavailable(self):
|
||||
"""Test health_check for unavailable provider."""
|
||||
provider = ConcreteTranslationProvider(available=False)
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is False
|
||||
assert status.error == "Provider not available"
|
||||
|
||||
def test_get_name_abstract(self):
|
||||
"""Test that get_name is abstract."""
|
||||
assert "get_name" in TranslationProvider.__abstractmethods__
|
||||
|
||||
def test_is_available_abstract(self):
|
||||
"""Test that is_available is abstract."""
|
||||
assert "is_available" in TranslationProvider.__abstractmethods__
|
||||
|
||||
def test_translate_text_abstract(self):
|
||||
"""Test that translate_text is abstract."""
|
||||
assert "translate_text" in TranslationProvider.__abstractmethods__
|
||||
488
tests/test_providers/test_deepl_provider.py
Normal file
488
tests/test_providers/test_deepl_provider.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
Tests for the DeepLTranslationProvider.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import pytest
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from services.providers.deepl_provider import (
|
||||
DeepLTranslationProvider,
|
||||
DeepLProviderError,
|
||||
get_deepl_provider,
|
||||
register_deepl_provider,
|
||||
DEEPL_QUOTA_EXCEEDED,
|
||||
DEEPL_INVALID_KEY,
|
||||
DEEPL_NETWORK_ERROR,
|
||||
DEEPL_UNSUPPORTED_LANGUAGE,
|
||||
DEEPL_TEXT_TOO_LONG,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
|
||||
|
||||
class TestDeepLProviderError:
|
||||
"""Tests for DeepLProviderError exception."""
|
||||
|
||||
def test_error_creation(self):
|
||||
"""Test error creation with all fields."""
|
||||
error = DeepLProviderError(
|
||||
code=DEEPL_INVALID_KEY,
|
||||
message="Invalid API key",
|
||||
details={"provider": "deepl"},
|
||||
)
|
||||
|
||||
assert error.code == DEEPL_INVALID_KEY
|
||||
assert error.message == "Invalid API key"
|
||||
assert error.details == {"provider": "deepl"}
|
||||
|
||||
def test_error_to_dict(self):
|
||||
"""Test error serialization."""
|
||||
error = DeepLProviderError(
|
||||
code=DEEPL_QUOTA_EXCEEDED,
|
||||
message="Quota exceeded",
|
||||
details={"reset_at": "2024-01-16T00:00:00Z"},
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == DEEPL_QUOTA_EXCEEDED
|
||||
assert result["message"] == "Quota exceeded"
|
||||
assert result["details"]["reset_at"] == "2024-01-16T00:00:00Z"
|
||||
|
||||
def test_error_to_dict_no_details(self):
|
||||
"""Test error serialization without details."""
|
||||
error = DeepLProviderError(
|
||||
code=DEEPL_NETWORK_ERROR,
|
||||
message="Network error",
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == DEEPL_NETWORK_ERROR
|
||||
assert result["message"] == "Network error"
|
||||
assert "details" not in result
|
||||
|
||||
|
||||
class TestDeepLTranslationProvider:
|
||||
"""Tests for DeepLTranslationProvider."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create a DeepL provider instance with Pro key."""
|
||||
return DeepLTranslationProvider(
|
||||
api_key="test-pro-key-12345",
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def provider_free(self):
|
||||
"""Create a DeepL provider instance with Free tier key."""
|
||||
return DeepLTranslationProvider(
|
||||
api_key="test-free-key:fx",
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
def test_init_requires_api_key(self):
|
||||
"""Test that initialization requires API key."""
|
||||
with pytest.raises(ValueError, match="API key is required"):
|
||||
DeepLTranslationProvider(api_key="")
|
||||
|
||||
def test_get_name(self, provider):
|
||||
"""Test provider name."""
|
||||
assert provider.get_name() == "deepl"
|
||||
|
||||
def test_detect_api_type_pro(self, provider):
|
||||
"""Test Pro API key detection."""
|
||||
assert provider._api_type == "pro"
|
||||
|
||||
def test_detect_api_type_free(self, provider_free):
|
||||
"""Test Free API key detection."""
|
||||
assert provider_free._api_type == "free"
|
||||
|
||||
def test_get_api_url_pro(self, provider):
|
||||
"""Test Pro API URL."""
|
||||
url = provider._get_api_url()
|
||||
assert url == "https://api.deepl.com/v2/translate"
|
||||
|
||||
def test_get_api_url_free(self, provider_free):
|
||||
"""Test Free API URL."""
|
||||
url = provider_free._get_api_url()
|
||||
assert url == "https://api-free.deepl.com/v2/translate"
|
||||
|
||||
def test_normalize_language_code_uppercase(self, provider):
|
||||
"""Test language code normalization to uppercase."""
|
||||
assert provider._normalize_language_code("en") == "EN-US"
|
||||
assert provider._normalize_language_code("fr") == "FR"
|
||||
assert provider._normalize_language_code("pt") == "PT-BR"
|
||||
|
||||
def test_normalize_language_code_preserves_variant(self, provider):
|
||||
"""Test that language variants are preserved."""
|
||||
assert provider._normalize_language_code("en-gb") == "EN-GB"
|
||||
assert provider._normalize_language_code("en-us") == "EN-US"
|
||||
assert provider._normalize_language_code("pt-pt") == "PT-PT"
|
||||
|
||||
def test_normalize_language_code_auto(self, provider):
|
||||
"""Test auto language code handling."""
|
||||
assert provider._normalize_language_code("auto") == ""
|
||||
assert provider._normalize_language_code("") == ""
|
||||
|
||||
def test_is_language_supported(self, provider):
|
||||
"""Test language support checking."""
|
||||
assert provider._is_language_supported("en") is True
|
||||
assert provider._is_language_supported("fr") is True
|
||||
assert provider._is_language_supported("EN-US") is True
|
||||
assert provider._is_language_supported("XX") is False
|
||||
|
||||
def test_translate_text_empty(self, provider):
|
||||
"""Test translating empty text."""
|
||||
request = TranslationRequest(text="", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == ""
|
||||
assert response.provider_name == "deepl"
|
||||
assert response.from_cache is False
|
||||
|
||||
def test_translate_text_whitespace(self, provider):
|
||||
"""Test translating whitespace-only text."""
|
||||
request = TranslationRequest(text=" ", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == " "
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_translate_text_success(self, mock_get_translator, provider):
|
||||
"""Test successful translation."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.return_value = "Bonjour"
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "deepl"
|
||||
assert response.from_cache is False
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_translate_text_with_source_language(self, mock_get_translator, provider):
|
||||
"""Test translation with explicit source language."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.return_value = "Bonjour"
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
request = TranslationRequest(
|
||||
text="Hello", target_language="fr", source_language="en"
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
|
||||
def test_translate_text_same_language_skip(self, provider):
|
||||
"""Test that translation is skipped when source == target."""
|
||||
request = TranslationRequest(
|
||||
text="Hello",
|
||||
target_language="en",
|
||||
source_language="en",
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Hello"
|
||||
assert response.from_cache is False
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_translate_text_error_fallback(self, mock_get_translator, provider):
|
||||
"""Test that translation errors return original text and structured error."""
|
||||
mock_get_translator.side_effect = Exception("API Error")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Hello"
|
||||
assert response.provider_name == "deepl"
|
||||
assert response.error is not None
|
||||
assert response.error_code is not None
|
||||
|
||||
def test_translate_batch_empty(self, provider):
|
||||
"""Test batch translation with empty list."""
|
||||
responses = provider.translate_batch([])
|
||||
assert responses == []
|
||||
|
||||
@patch.object(DeepLTranslationProvider, "translate_text")
|
||||
def test_translate_batch(self, mock_translate, provider):
|
||||
"""Test batch translation."""
|
||||
mock_translate.side_effect = [
|
||||
TranslationResponse(translated_text="Bonjour", provider_name="deepl"),
|
||||
TranslationResponse(translated_text="Monde", provider_name="deepl"),
|
||||
]
|
||||
|
||||
requests = [
|
||||
TranslationRequest(text="Hello", target_language="fr"),
|
||||
TranslationRequest(text="World", target_language="fr"),
|
||||
]
|
||||
responses = provider.translate_batch(requests)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert responses[0].translated_text == "Bonjour"
|
||||
assert responses[1].translated_text == "Monde"
|
||||
|
||||
def test_health_check(self, provider):
|
||||
"""Test health check."""
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "deepl"
|
||||
assert isinstance(status.available, bool)
|
||||
assert status.latency_ms is not None
|
||||
|
||||
|
||||
class TestDeepLErrorCodes:
|
||||
"""Tests for DeepL error code handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create a DeepL provider instance."""
|
||||
return DeepLTranslationProvider(
|
||||
api_key="test-key-12345",
|
||||
use_cache=False,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_quota_exceeded_error(self, mock_get_translator, provider):
|
||||
"""Test quota exceeded error handling."""
|
||||
mock_get_translator.side_effect = Exception("quota exceeded")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == DEEPL_QUOTA_EXCEEDED
|
||||
assert "quota" in response.error.lower() or "dépassé" in response.error.lower()
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_invalid_key_error(self, mock_get_translator, provider):
|
||||
"""Test invalid API key error handling."""
|
||||
mock_get_translator.side_effect = Exception("403 Forbidden - invalid auth")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == DEEPL_INVALID_KEY
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_unsupported_language_error(self, mock_get_translator, provider):
|
||||
"""Test unsupported language error handling."""
|
||||
mock_get_translator.side_effect = Exception("language not supported")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == DEEPL_UNSUPPORTED_LANGUAGE
|
||||
|
||||
def test_text_too_long_error(self, provider):
|
||||
"""Test text too long error handling."""
|
||||
long_text = "x" * (200 * 1024)
|
||||
request = TranslationRequest(text=long_text, target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == DEEPL_TEXT_TOO_LONG
|
||||
assert response.error_details is not None
|
||||
assert "text_length" in response.error_details or "max_length" in response.error_details
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_timeout_exception_maps_to_network_error(self, mock_get_translator, provider):
|
||||
"""Test that socket.timeout and FuturesTimeoutError map to DEEPL_NETWORK_ERROR."""
|
||||
mock_get_translator.side_effect = FuturesTimeoutError()
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == DEEPL_NETWORK_ERROR
|
||||
assert response.error is not None
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_socket_timeout_maps_to_network_error(self, mock_get_translator, provider):
|
||||
"""Test that socket.timeout maps to DEEPL_NETWORK_ERROR."""
|
||||
mock_get_translator.side_effect = socket.timeout("timed out")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == DEEPL_NETWORK_ERROR
|
||||
|
||||
|
||||
class TestDeepLProviderCaching:
|
||||
"""Tests for DeepL provider caching functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cache(self):
|
||||
"""Create a mock cache."""
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
return cache
|
||||
|
||||
def test_cache_hit(self, mock_cache):
|
||||
"""Test that cache hits return cached result."""
|
||||
mock_cache.get.return_value = "Cached Translation"
|
||||
|
||||
provider = DeepLTranslationProvider(
|
||||
api_key="test-key-12345",
|
||||
use_cache=True,
|
||||
)
|
||||
provider._cache = mock_cache
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Cached Translation"
|
||||
assert response.from_cache is True
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_cache_set_on_miss(self, mock_get_translator, mock_cache):
|
||||
"""Test that translations are cached on miss."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.return_value = "Bonjour"
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
provider = DeepLTranslationProvider(
|
||||
api_key="test-key-12345",
|
||||
use_cache=True,
|
||||
)
|
||||
provider._cache = mock_cache
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
provider.translate_text(request)
|
||||
|
||||
mock_cache.set.assert_called_once()
|
||||
|
||||
|
||||
class TestDeepLProviderRetry:
|
||||
"""Tests for DeepL provider retry logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create a DeepL provider with retry enabled."""
|
||||
return DeepLTranslationProvider(
|
||||
api_key="test-key-12345",
|
||||
use_cache=False,
|
||||
max_retries=2,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_retry_on_network_error(self, mock_get_translator, provider):
|
||||
"""Test that network errors trigger retry."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.side_effect = [
|
||||
Exception("timeout"),
|
||||
"Bonjour",
|
||||
]
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert mock_translator.translate.call_count == 2
|
||||
|
||||
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
|
||||
def test_no_retry_on_invalid_key(self, mock_get_translator, provider):
|
||||
"""Test that invalid key errors do not trigger retry."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.side_effect = Exception("401 invalid auth")
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
provider.translate_text(request)
|
||||
|
||||
assert mock_translator.translate.call_count == 1
|
||||
|
||||
|
||||
class TestDeepLProviderSingleton:
|
||||
"""Tests for DeepL provider singleton functions."""
|
||||
|
||||
def test_get_deepl_provider_no_config(self):
|
||||
"""Test get_deepl_provider returns None without config."""
|
||||
import services.providers.deepl_provider as deepl_module
|
||||
|
||||
deepl_module._provider_instance = None
|
||||
|
||||
with patch("services.providers.config.ProvidersConfig") as mock_config:
|
||||
mock_config.DEEPL_API_KEY = ""
|
||||
result = deepl_module.get_deepl_provider()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_deepl_provider_with_config(self):
|
||||
"""Test get_deepl_provider creates instance with config."""
|
||||
import services.providers.deepl_provider as deepl_module
|
||||
|
||||
deepl_module._provider_instance = None
|
||||
|
||||
with patch("services.providers.config.ProvidersConfig") as mock_config:
|
||||
mock_config.DEEPL_API_KEY = "test-key:fx"
|
||||
mock_config.DEEPL_TIMEOUT = 30
|
||||
mock_config.DEEPL_MAX_RETRIES = 3
|
||||
mock_config.DEEPL_RETRY_DELAY = 1.0
|
||||
|
||||
provider = deepl_module.get_deepl_provider()
|
||||
|
||||
assert provider is not None
|
||||
assert provider._api_type == "free"
|
||||
|
||||
deepl_module._provider_instance = None
|
||||
|
||||
|
||||
class TestDeepLRegistryIntegration:
|
||||
"""Tests for DeepL provider registry integration."""
|
||||
|
||||
def test_register_deepl_provider(self):
|
||||
"""Test provider registration."""
|
||||
from services.providers.registry import registry
|
||||
|
||||
registry.unregister("deepl")
|
||||
|
||||
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
|
||||
mock_provider = MagicMock()
|
||||
mock_get.return_value = mock_provider
|
||||
|
||||
from services.providers.deepl_provider import register_deepl_provider
|
||||
|
||||
result = register_deepl_provider()
|
||||
|
||||
assert result == mock_provider
|
||||
assert "deepl" in registry
|
||||
registry.unregister("deepl")
|
||||
|
||||
def test_register_deepl_provider_no_config(self):
|
||||
"""Test provider registration when not configured."""
|
||||
from services.providers.registry import registry
|
||||
|
||||
registry.unregister("deepl")
|
||||
|
||||
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
|
||||
mock_get.return_value = None
|
||||
|
||||
from services.providers.deepl_provider import register_deepl_provider
|
||||
|
||||
result = register_deepl_provider()
|
||||
|
||||
assert result is None
|
||||
assert "deepl" not in registry
|
||||
|
||||
|
||||
class TestLegacyDeepLAdapter:
|
||||
"""Tests for LegacyDeepLAdapter."""
|
||||
|
||||
def test_adapter_not_configured(self):
|
||||
"""Test adapter when DeepL is not configured."""
|
||||
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
|
||||
mock_get.return_value = None
|
||||
|
||||
from services.providers.deepl_provider import LegacyDeepLAdapter
|
||||
|
||||
adapter = LegacyDeepLAdapter()
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
adapter.translate("Hello", "fr")
|
||||
|
||||
assert "not configured" in str(exc_info.value).lower()
|
||||
585
tests/test_providers/test_fallback.py
Normal file
585
tests/test_providers/test_fallback.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Tests for the fallback translation service.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from services.providers.fallback import (
|
||||
translate_with_fallback,
|
||||
translate_with_fallback_by_mode,
|
||||
AllProvidersFailedError,
|
||||
ALL_PROVIDERS_FAILED,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
from services.providers.registry import registry
|
||||
|
||||
|
||||
class TestAllProvidersFailedError:
|
||||
"""Tests for AllProvidersFailedError exception."""
|
||||
|
||||
def test_error_creation_defaults(self):
|
||||
"""Test error creation with default values."""
|
||||
error = AllProvidersFailedError()
|
||||
|
||||
assert error.code == ALL_PROVIDERS_FAILED
|
||||
assert "Tous les fournisseurs" in error.message
|
||||
assert error.providers_tried == []
|
||||
assert error.errors == []
|
||||
|
||||
def test_error_creation_with_details(self):
|
||||
"""Test error creation with specific details."""
|
||||
error = AllProvidersFailedError(
|
||||
message="Custom error message",
|
||||
providers_tried=["google", "deepl"],
|
||||
errors=[
|
||||
{"provider": "google", "error_code": "RATE_LIMITED"},
|
||||
{"provider": "deepl", "error_code": "TIMEOUT"},
|
||||
],
|
||||
)
|
||||
|
||||
assert error.code == ALL_PROVIDERS_FAILED
|
||||
assert error.message == "Custom error message"
|
||||
assert error.providers_tried == ["google", "deepl"]
|
||||
assert len(error.errors) == 2
|
||||
|
||||
def test_error_to_dict(self):
|
||||
"""Test error serialization to dict."""
|
||||
error = AllProvidersFailedError(
|
||||
providers_tried=["google", "deepl"],
|
||||
errors=[
|
||||
{
|
||||
"provider": "google",
|
||||
"error_code": "RATE_LIMITED",
|
||||
"message": "Rate limit",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == ALL_PROVIDERS_FAILED
|
||||
assert "Tous les fournisseurs" in result["message"]
|
||||
assert result["details"]["providers_tried"] == ["google", "deepl"]
|
||||
assert result["details"]["error_count"] == 1
|
||||
assert "last_error" in result["details"]
|
||||
|
||||
def test_error_to_dict_no_errors(self):
|
||||
"""Test error serialization without errors."""
|
||||
error = AllProvidersFailedError(providers_tried=[])
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["details"]["error_count"] == 0
|
||||
assert "last_error" not in result["details"]
|
||||
|
||||
|
||||
class TestTranslateWithFallback:
|
||||
"""Tests for translate_with_fallback function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry(self):
|
||||
"""Clean up registry before each test."""
|
||||
# Save original providers
|
||||
original_providers = dict(registry._providers)
|
||||
registry.clear()
|
||||
yield
|
||||
# Restore original providers
|
||||
registry.clear()
|
||||
for name, provider in original_providers.items():
|
||||
registry.register(name, provider)
|
||||
|
||||
def test_empty_provider_list(self):
|
||||
"""Test that empty provider list raises error."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with pytest.raises(AllProvidersFailedError) as exc_info:
|
||||
translate_with_fallback(request, [])
|
||||
|
||||
assert exc_info.value.code == ALL_PROVIDERS_FAILED
|
||||
assert exc_info.value.providers_tried == []
|
||||
|
||||
def test_single_provider_success(self):
|
||||
"""Test successful translation with single provider."""
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.is_available.return_value = True
|
||||
mock_provider.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="google",
|
||||
)
|
||||
registry.register("google", mock_provider)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google"])
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "google"
|
||||
assert response.error is None
|
||||
mock_provider.translate_text.assert_called_once()
|
||||
|
||||
def test_first_provider_succeeds(self):
|
||||
"""Test that first provider is used when it succeeds."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = True
|
||||
mock_google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="google",
|
||||
)
|
||||
registry.register("google", mock_google)
|
||||
|
||||
mock_deepl = MagicMock()
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "google"
|
||||
mock_google.translate_text.assert_called_once()
|
||||
mock_deepl.translate_text.assert_not_called()
|
||||
|
||||
def test_fallback_on_provider_error(self):
|
||||
"""Test fallback when first provider returns error."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = True
|
||||
mock_google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="google",
|
||||
error="Rate limit exceeded",
|
||||
error_code="RATE_LIMITED",
|
||||
)
|
||||
registry.register("google", mock_google)
|
||||
|
||||
mock_deepl = MagicMock()
|
||||
mock_deepl.is_available.return_value = True
|
||||
mock_deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="deepl",
|
||||
)
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "deepl"
|
||||
mock_google.translate_text.assert_called_once()
|
||||
mock_deepl.translate_text.assert_called_once()
|
||||
|
||||
def test_fallback_on_provider_exception(self):
|
||||
"""Test fallback when first provider raises exception."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = True
|
||||
mock_google.translate_text.side_effect = Exception("Connection failed")
|
||||
registry.register("google", mock_google)
|
||||
|
||||
mock_deepl = MagicMock()
|
||||
mock_deepl.is_available.return_value = True
|
||||
mock_deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="deepl",
|
||||
)
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "deepl"
|
||||
|
||||
def test_all_providers_fail(self):
|
||||
"""Test that error is raised when all providers fail."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = True
|
||||
mock_google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="google",
|
||||
error="Rate limit",
|
||||
error_code="RATE_LIMITED",
|
||||
)
|
||||
registry.register("google", mock_google)
|
||||
|
||||
mock_deepl = MagicMock()
|
||||
mock_deepl.is_available.return_value = True
|
||||
mock_deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="deepl",
|
||||
error="Timeout",
|
||||
error_code="TIMEOUT",
|
||||
)
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with pytest.raises(AllProvidersFailedError) as exc_info:
|
||||
translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
assert exc_info.value.code == ALL_PROVIDERS_FAILED
|
||||
assert "google" in exc_info.value.providers_tried
|
||||
assert "deepl" in exc_info.value.providers_tried
|
||||
assert len(exc_info.value.errors) == 2
|
||||
|
||||
def test_skip_unavailable_provider(self):
|
||||
"""Test that unavailable providers are skipped."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = False
|
||||
registry.register("google", mock_google)
|
||||
|
||||
mock_deepl = MagicMock()
|
||||
mock_deepl.is_available.return_value = True
|
||||
mock_deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="deepl",
|
||||
)
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "deepl"
|
||||
mock_google.translate_text.assert_not_called()
|
||||
mock_deepl.translate_text.assert_called_once()
|
||||
|
||||
def test_try_unavailable_when_skip_disabled(self):
|
||||
"""Test unavailable providers are tried when skip_unavailable=False."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = False
|
||||
mock_google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="google",
|
||||
)
|
||||
registry.register("google", mock_google)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google"], skip_unavailable=False)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
mock_google.translate_text.assert_called_once()
|
||||
|
||||
def test_provider_not_registered(self):
|
||||
"""Test handling of unregistered provider names."""
|
||||
mock_deepl = MagicMock()
|
||||
mock_deepl.is_available.return_value = True
|
||||
mock_deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="deepl",
|
||||
)
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["unknown", "deepl"])
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "deepl"
|
||||
|
||||
def test_response_provider_name_set(self):
|
||||
"""Test that provider_name is set in response even if not present."""
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.is_available.return_value = True
|
||||
# Response without provider_name
|
||||
mock_provider.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="", # Empty
|
||||
)
|
||||
registry.register("test_provider", mock_provider)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["test_provider"])
|
||||
|
||||
assert response.provider_name == "test_provider"
|
||||
|
||||
def test_logs_failed_attempts(self):
|
||||
"""Test that failed attempts are logged."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = True
|
||||
mock_google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="google",
|
||||
error="Rate limit",
|
||||
error_code="RATE_LIMITED",
|
||||
)
|
||||
registry.register("google", mock_google)
|
||||
|
||||
mock_deepl = MagicMock()
|
||||
mock_deepl.is_available.return_value = True
|
||||
mock_deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour",
|
||||
provider_name="deepl",
|
||||
)
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
with patch("services.providers.fallback._log_warning") as mock_log:
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
# Should log the failed attempt
|
||||
mock_log.assert_any_call(
|
||||
"fallback_provider_error",
|
||||
provider="google",
|
||||
error_code="RATE_LIMITED",
|
||||
error_message="Rate limit",
|
||||
)
|
||||
|
||||
|
||||
class TestTranslateWithFallbackByMode:
|
||||
"""Tests for translate_with_fallback_by_mode function."""
|
||||
|
||||
@patch("services.providers.fallback.translate_with_fallback")
|
||||
def test_classic_mode(self, mock_translate):
|
||||
"""Test classic mode uses classic fallback chain."""
|
||||
mock_translate.return_value = MagicMock()
|
||||
|
||||
with patch("services.providers.config.ProvidersConfig") as mock_config:
|
||||
mock_config.get_fallback_chain.return_value = ["google", "deepl"]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
translate_with_fallback_by_mode(request, mode="classic")
|
||||
|
||||
mock_config.get_fallback_chain.assert_called_once_with("classic")
|
||||
mock_translate.assert_called_once()
|
||||
|
||||
@patch("services.providers.fallback.translate_with_fallback")
|
||||
def test_llm_mode(self, mock_translate):
|
||||
"""Test LLM mode uses LLM fallback chain."""
|
||||
mock_translate.return_value = MagicMock()
|
||||
|
||||
with patch("services.providers.config.ProvidersConfig") as mock_config:
|
||||
mock_config.get_fallback_chain.return_value = ["ollama", "openai"]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
translate_with_fallback_by_mode(request, mode="llm")
|
||||
|
||||
mock_config.get_fallback_chain.assert_called_once_with("llm")
|
||||
|
||||
@patch("services.providers.fallback.translate_with_fallback")
|
||||
def test_auto_mode(self, mock_translate):
|
||||
"""Test auto mode uses general fallback chain."""
|
||||
mock_translate.return_value = MagicMock()
|
||||
|
||||
with patch("services.providers.config.ProvidersConfig") as mock_config:
|
||||
mock_config.get_fallback_chain.return_value = ["google", "deepl", "openai"]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
translate_with_fallback_by_mode(request, mode="auto")
|
||||
|
||||
mock_config.get_fallback_chain.assert_called_once_with("auto")
|
||||
|
||||
def test_empty_chain_raises_error(self):
|
||||
"""Test that empty chain raises AllProvidersFailedError."""
|
||||
with patch("services.providers.config.ProvidersConfig") as mock_config:
|
||||
mock_config.get_fallback_chain.return_value = []
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with pytest.raises(AllProvidersFailedError) as exc_info:
|
||||
translate_with_fallback_by_mode(request, mode="classic")
|
||||
|
||||
assert exc_info.value.code == ALL_PROVIDERS_FAILED
|
||||
assert "classic" in exc_info.value.message
|
||||
|
||||
|
||||
class TestFallbackChainOrder:
|
||||
"""Tests for fallback chain ordering and behavior."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry(self):
|
||||
"""Clean up registry before each test."""
|
||||
original_providers = dict(registry._providers)
|
||||
registry.clear()
|
||||
yield
|
||||
registry.clear()
|
||||
for name, provider in original_providers.items():
|
||||
registry.register(name, provider)
|
||||
|
||||
def test_chain_order_respected(self):
|
||||
"""Test that providers are tried in the exact order specified."""
|
||||
call_order = []
|
||||
|
||||
def create_mock_provider(name, should_succeed):
|
||||
mock = MagicMock()
|
||||
mock.is_available.return_value = True
|
||||
if should_succeed:
|
||||
mock.translate_text.side_effect = lambda req: (
|
||||
call_order.append(name),
|
||||
TranslationResponse(
|
||||
translated_text=f"{name}_result", provider_name=name
|
||||
),
|
||||
)[1]
|
||||
else:
|
||||
mock.translate_text.side_effect = lambda req: (
|
||||
call_order.append(name),
|
||||
TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name=name,
|
||||
error="Failed",
|
||||
error_code="FAILED",
|
||||
),
|
||||
)[1]
|
||||
return mock
|
||||
|
||||
# All fail except last
|
||||
registry.register("first", create_mock_provider("first", False))
|
||||
registry.register("second", create_mock_provider("second", False))
|
||||
registry.register("third", create_mock_provider("third", True))
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["first", "second", "third"])
|
||||
|
||||
assert call_order == ["first", "second", "third"]
|
||||
assert response.translated_text == "third_result"
|
||||
|
||||
def test_partial_chain_stops_on_success(self):
|
||||
"""Test that chain stops when a provider succeeds."""
|
||||
mock_first = MagicMock()
|
||||
mock_first.is_available.return_value = True
|
||||
mock_first.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Result",
|
||||
provider_name="first",
|
||||
)
|
||||
registry.register("first", mock_first)
|
||||
|
||||
mock_second = MagicMock()
|
||||
registry.register("second", mock_second)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
translate_with_fallback(request, ["first", "second"])
|
||||
|
||||
mock_first.translate_text.assert_called_once()
|
||||
mock_second.translate_text.assert_not_called()
|
||||
|
||||
def test_error_details_accumulated(self):
|
||||
"""Test that error details from all providers are accumulated."""
|
||||
mock_google = MagicMock()
|
||||
mock_google.is_available.return_value = True
|
||||
mock_google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="google",
|
||||
error="Google error",
|
||||
error_code="GOOGLE_ERROR",
|
||||
)
|
||||
registry.register("google", mock_google)
|
||||
|
||||
mock_deepl = MagicMock()
|
||||
mock_deepl.is_available.return_value = True
|
||||
mock_deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="deepl",
|
||||
error="DeepL error",
|
||||
error_code="DEEPL_ERROR",
|
||||
)
|
||||
registry.register("deepl", mock_deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with pytest.raises(AllProvidersFailedError) as exc_info:
|
||||
translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
errors = exc_info.value.errors
|
||||
assert len(errors) == 2
|
||||
assert errors[0]["provider"] == "google"
|
||||
assert errors[0]["error_code"] == "GOOGLE_ERROR"
|
||||
assert errors[1]["provider"] == "deepl"
|
||||
assert errors[1]["error_code"] == "DEEPL_ERROR"
|
||||
|
||||
|
||||
class TestIntegrationWithRealRegistry:
|
||||
"""Integration-style tests with real registry."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry(self):
|
||||
"""Clean up registry before each test."""
|
||||
original_providers = dict(registry._providers)
|
||||
registry.clear()
|
||||
yield
|
||||
registry.clear()
|
||||
for name, provider in original_providers.items():
|
||||
registry.register(name, provider)
|
||||
|
||||
def test_end_to_end_success(self):
|
||||
"""End-to-end test with mocked providers in real registry."""
|
||||
# Create realistic mock providers
|
||||
google = MagicMock()
|
||||
google.is_available.return_value = True
|
||||
google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour from Google",
|
||||
provider_name="google",
|
||||
)
|
||||
|
||||
deepl = MagicMock()
|
||||
deepl.is_available.return_value = True
|
||||
deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour from DeepL",
|
||||
provider_name="deepl",
|
||||
)
|
||||
|
||||
# Register them
|
||||
registry.register("google", google)
|
||||
registry.register("deepl", deepl)
|
||||
|
||||
# Test: First succeeds
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
assert response.translated_text == "Bonjour from Google"
|
||||
assert response.provider_name == "google"
|
||||
|
||||
def test_end_to_end_fallback(self):
|
||||
"""End-to-end test with fallback scenario."""
|
||||
google = MagicMock()
|
||||
google.is_available.return_value = True
|
||||
google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="google",
|
||||
error="Rate limit",
|
||||
error_code="RATE_LIMITED",
|
||||
)
|
||||
|
||||
deepl = MagicMock()
|
||||
deepl.is_available.return_value = True
|
||||
deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="Bonjour from DeepL",
|
||||
provider_name="deepl",
|
||||
)
|
||||
|
||||
registry.register("google", google)
|
||||
registry.register("deepl", deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
assert response.translated_text == "Bonjour from DeepL"
|
||||
assert response.provider_name == "deepl"
|
||||
|
||||
def test_end_to_end_all_fail(self):
|
||||
"""End-to-end test when all providers fail."""
|
||||
google = MagicMock()
|
||||
google.is_available.return_value = True
|
||||
google.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="google",
|
||||
error="Google failed",
|
||||
error_code="GOOGLE_FAIL",
|
||||
)
|
||||
|
||||
deepl = MagicMock()
|
||||
deepl.is_available.return_value = True
|
||||
deepl.translate_text.return_value = TranslationResponse(
|
||||
translated_text="",
|
||||
provider_name="deepl",
|
||||
error="DeepL failed",
|
||||
error_code="DEEPL_FAIL",
|
||||
)
|
||||
|
||||
registry.register("google", google)
|
||||
registry.register("deepl", deepl)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with pytest.raises(AllProvidersFailedError) as exc_info:
|
||||
translate_with_fallback(request, ["google", "deepl"])
|
||||
|
||||
result = exc_info.value.to_dict()
|
||||
assert result["error"] == ALL_PROVIDERS_FAILED
|
||||
assert result["details"]["providers_tried"] == ["google", "deepl"]
|
||||
assert result["details"]["last_error"]["provider"] == "deepl"
|
||||
422
tests/test_providers/test_google_integration.py
Normal file
422
tests/test_providers/test_google_integration.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Integration tests for GoogleTranslationProvider.
|
||||
|
||||
Tests for error handling, retry logic, and health checks.
|
||||
Uses mocking to simulate various API error scenarios.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import time
|
||||
|
||||
from services.providers.google_provider import (
|
||||
GoogleTranslationProvider,
|
||||
GoogleProviderError,
|
||||
GOOGLE_QUOTA_EXCEEDED,
|
||||
GOOGLE_INVALID_KEY,
|
||||
GOOGLE_NETWORK_ERROR,
|
||||
GOOGLE_UNSUPPORTED_LANGUAGE,
|
||||
GOOGLE_TEXT_TOO_LONG,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest
|
||||
|
||||
|
||||
class TestGoogleProviderHealthCheck:
|
||||
"""Tests for health check functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_health_check_returns_status(self, provider):
|
||||
"""Test health check returns ProviderHealthStatus."""
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "google"
|
||||
assert isinstance(status.available, bool)
|
||||
assert status.latency_ms is not None
|
||||
|
||||
def test_health_check_includes_last_check_timestamp(self, provider):
|
||||
"""Test health check includes last_check timestamp."""
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.last_check is not None
|
||||
assert "T" in status.last_check # ISO format
|
||||
|
||||
def test_health_check_caches_result(self, provider):
|
||||
"""Test health check result is cached for 60 seconds."""
|
||||
status1 = provider.health_check()
|
||||
status2 = provider.health_check()
|
||||
|
||||
assert status1.last_check == status2.last_check
|
||||
|
||||
def test_health_check_cache_ttl(self, provider):
|
||||
"""Test health check cache expires after TTL."""
|
||||
provider._health_cache_ttl = 0.1 # 100ms TTL for testing
|
||||
|
||||
status1 = provider.health_check()
|
||||
time.sleep(0.15)
|
||||
status2 = provider.health_check()
|
||||
|
||||
assert status1.last_check != status2.last_check
|
||||
|
||||
|
||||
class TestGoogleProviderErrorCodes:
|
||||
"""Tests for specific Google error codes."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_quota_exceeded_error(self, provider):
|
||||
"""Test GOOGLE_QUOTA_EXCEEDED error on 429 response."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_QUOTA_EXCEEDED,
|
||||
message="Quota Google Translate dépassé. Réessayez demain.",
|
||||
details={"reset_at": "2024-01-16T00:00:00Z"},
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code == GOOGLE_QUOTA_EXCEEDED
|
||||
assert (
|
||||
"quota" in response.error.lower() or "dépassé" in response.error.lower()
|
||||
)
|
||||
|
||||
def test_invalid_key_error(self, provider):
|
||||
"""Test GOOGLE_INVALID_KEY error on 401 response."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_INVALID_KEY,
|
||||
message="Clé API Google invalide. Contactez l'administrateur.",
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code == GOOGLE_INVALID_KEY
|
||||
|
||||
def test_network_error(self, provider):
|
||||
"""Test GOOGLE_NETWORK_ERROR on timeout/connection error."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_NETWORK_ERROR,
|
||||
message="Service Google Translate indisponible. Réessayez.",
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code == GOOGLE_NETWORK_ERROR
|
||||
|
||||
def test_unsupported_language_error(self, provider):
|
||||
"""Test GOOGLE_UNSUPPORTED_LANGUAGE for invalid language."""
|
||||
request = TranslationRequest(text="Hello", target_language="xx")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_UNSUPPORTED_LANGUAGE,
|
||||
message="Langue 'xx' non supportée par Google.",
|
||||
details={"unsupported_language": "xx"},
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code == GOOGLE_UNSUPPORTED_LANGUAGE
|
||||
|
||||
def test_text_too_long_error(self, provider):
|
||||
"""Test GOOGLE_TEXT_TOO_LONG for text exceeding limit."""
|
||||
long_text = "x" * 5001
|
||||
request = TranslationRequest(text=long_text, target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_TEXT_TOO_LONG,
|
||||
message="Texte trop long (max 5000 caractères par requête).",
|
||||
details={"text_length": 5001, "max_length": 5000},
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code == GOOGLE_TEXT_TOO_LONG
|
||||
|
||||
|
||||
class TestGoogleProviderRetryLogic:
|
||||
"""Tests for retry logic with exponential backoff."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_retry_on_transient_error(self, provider):
|
||||
"""Test that transient errors trigger retry."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_api_call(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise GoogleProviderError(
|
||||
code=GOOGLE_NETWORK_ERROR, message="Temporary network error"
|
||||
)
|
||||
return "Bonjour"
|
||||
|
||||
with patch.object(provider, "_make_api_request", side_effect=mock_api_call):
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert call_count == 3
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.error is None
|
||||
|
||||
def test_max_retries_exceeded(self, provider):
|
||||
"""Test that max retries is respected."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_NETWORK_ERROR, message="Network error"
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert mock_api.call_count == 4 # Initial + 3 retries
|
||||
|
||||
def test_no_retry_on_invalid_key(self, provider):
|
||||
"""Test that invalid key errors don't retry."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_INVALID_KEY, message="Invalid key"
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert mock_api.call_count == 1 # No retries for auth errors
|
||||
|
||||
|
||||
class TestGoogleProviderTimeout:
|
||||
"""Tests for timeout configuration."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_default_timeout(self, provider):
|
||||
"""Test default timeout is 30 seconds."""
|
||||
assert provider.timeout == 30
|
||||
|
||||
def test_custom_timeout(self):
|
||||
"""Test custom timeout configuration."""
|
||||
provider = GoogleTranslationProvider(use_cache=False, timeout=60)
|
||||
assert provider.timeout == 60
|
||||
|
||||
def test_timeout_raises_network_error(self, provider):
|
||||
"""Test that timeout raises GOOGLE_NETWORK_ERROR."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
import socket
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = socket.timeout("Request timed out")
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code == GOOGLE_NETWORK_ERROR
|
||||
|
||||
|
||||
class TestGoogleProviderErrorFormat:
|
||||
"""Tests for JSON error format compliance."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_error_response_format(self, provider):
|
||||
"""Test that errors return JSON: {error, message, details?} format."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_QUOTA_EXCEEDED,
|
||||
message="Quota exceeded",
|
||||
details={"reset_at": "2024-01-16T00:00:00Z"},
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code is not None
|
||||
error_dict = response.to_error_dict()
|
||||
assert "error" in error_dict
|
||||
assert "message" in error_dict
|
||||
|
||||
def test_error_no_document_content_in_response(self, provider):
|
||||
"""Test that error response never contains document content."""
|
||||
sensitive_text = "SENSITIVE_DATA_12345"
|
||||
request = TranslationRequest(text=sensitive_text, target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_NETWORK_ERROR, message="Network error"
|
||||
)
|
||||
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert sensitive_text not in str(response.error)
|
||||
assert sensitive_text not in str(response.to_error_dict())
|
||||
|
||||
|
||||
class TestGoogleProviderLogging:
|
||||
"""Tests for structlog logging."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_error_logged_with_structlog(self, provider):
|
||||
"""Test errors are logged with structlog (no document content)."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_QUOTA_EXCEEDED, message="Quota exceeded"
|
||||
)
|
||||
|
||||
with patch("services.providers.google_provider.logger") as mock_logger:
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert mock_logger.error.called or mock_logger.warning.called
|
||||
|
||||
def test_log_contains_metadata_not_content(self, provider):
|
||||
"""Test logs contain metadata (text_length) not content."""
|
||||
request = TranslationRequest(text="Secret content", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request") as mock_api:
|
||||
mock_api.side_effect = GoogleProviderError(
|
||||
code=GOOGLE_NETWORK_ERROR, message="Network error"
|
||||
)
|
||||
|
||||
with patch("services.providers.google_provider.logger") as mock_logger:
|
||||
response = provider.translate_text(request)
|
||||
|
||||
if mock_logger.error.called:
|
||||
call_args = str(mock_logger.error.call_args)
|
||||
assert "Secret content" not in call_args
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestGoogleProviderRealAPI:
|
||||
"""Integration tests with real Google Translate API (via deep_translator)."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_real_translation_en_to_fr(self, provider):
|
||||
"""Test real translation from English to French."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is None
|
||||
assert response.translated_text.lower() in ["bonjour", "salut", "hello"]
|
||||
assert response.provider_name == "google"
|
||||
|
||||
def test_real_translation_with_auto_detect(self, provider):
|
||||
"""Test translation with automatic language detection."""
|
||||
request = TranslationRequest(
|
||||
text="Bonjour le monde", target_language="en", source_language="auto"
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error is None
|
||||
assert (
|
||||
"world" in response.translated_text.lower()
|
||||
or "hello" in response.translated_text.lower()
|
||||
)
|
||||
|
||||
def test_real_health_check(self, provider):
|
||||
"""Test real health check."""
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "google"
|
||||
assert status.available is True
|
||||
assert status.latency_ms is not None
|
||||
assert status.last_check is not None
|
||||
|
||||
def test_real_batch_translation(self, provider):
|
||||
"""Test real batch translation."""
|
||||
requests = [
|
||||
TranslationRequest(text="Hello", target_language="es"),
|
||||
TranslationRequest(text="World", target_language="es"),
|
||||
]
|
||||
responses = provider.translate_batch(requests)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert all(r.error is None for r in responses)
|
||||
assert "hola" in responses[0].translated_text.lower()
|
||||
assert "mundo" in responses[1].translated_text.lower()
|
||||
|
||||
|
||||
class TestGoogleProviderOptimization:
|
||||
"""Tests for API usage optimization."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
def test_skip_translation_same_language(self, provider):
|
||||
"""Test translation is skipped when source == target."""
|
||||
request = TranslationRequest(
|
||||
text="Hello World", target_language="en", source_language="en"
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Hello World"
|
||||
assert response.from_cache is False
|
||||
|
||||
def test_translation_not_skipped_auto_detect(self, provider):
|
||||
"""Test translation is not skipped with auto-detect."""
|
||||
request = TranslationRequest(
|
||||
text="Bonjour", target_language="en", source_language="auto"
|
||||
)
|
||||
|
||||
with patch.object(provider, "_make_api_request", return_value="Hello"):
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Hello"
|
||||
provider._make_api_request.assert_called_once()
|
||||
|
||||
def test_usage_metrics_logged(self, provider):
|
||||
"""Test that usage metrics are logged on success."""
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
|
||||
with patch.object(provider, "_make_api_request", return_value="Bonjour"):
|
||||
with patch("services.providers.google_provider.logger") as mock_logger:
|
||||
response = provider.translate_text(request)
|
||||
|
||||
# Check that success was logged with metrics
|
||||
success_calls = [
|
||||
call
|
||||
for call in mock_logger.info.call_args_list
|
||||
if "google_translation_success" in str(call)
|
||||
]
|
||||
assert len(success_calls) > 0
|
||||
log_msg = str(success_calls[0])
|
||||
assert "chars=" in log_msg
|
||||
assert "source_lang=" in log_msg
|
||||
180
tests/test_providers/test_google_provider.py
Normal file
180
tests/test_providers/test_google_provider.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Tests for the GoogleTranslationProvider.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from services.providers.google_provider import (
|
||||
GoogleTranslationProvider,
|
||||
get_google_provider,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
|
||||
|
||||
class TestGoogleTranslationProvider:
|
||||
"""Tests for GoogleTranslationProvider."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create a Google provider instance."""
|
||||
return GoogleTranslationProvider(use_cache=False)
|
||||
|
||||
@pytest.fixture
|
||||
def provider_with_cache(self):
|
||||
"""Create a Google provider with caching enabled."""
|
||||
return GoogleTranslationProvider(use_cache=True)
|
||||
|
||||
def test_get_name(self, provider):
|
||||
"""Test provider name."""
|
||||
assert provider.get_name() == "google"
|
||||
|
||||
def test_is_available(self, provider):
|
||||
"""Test availability check."""
|
||||
result = provider.is_available()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_translate_text_empty(self, provider):
|
||||
"""Test translating empty text."""
|
||||
request = TranslationRequest(text="", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == ""
|
||||
assert response.provider_name == "google"
|
||||
assert response.from_cache is False
|
||||
|
||||
def test_translate_text_whitespace(self, provider):
|
||||
"""Test translating whitespace-only text."""
|
||||
request = TranslationRequest(text=" ", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == " "
|
||||
|
||||
@patch(
|
||||
"services.providers.google_provider.GoogleTranslationProvider._get_translator"
|
||||
)
|
||||
def test_translate_text_success(self, mock_get_translator, provider):
|
||||
"""Test successful translation."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.return_value = "Bonjour"
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "google"
|
||||
assert response.from_cache is False
|
||||
|
||||
@patch(
|
||||
"services.providers.google_provider.GoogleTranslationProvider._get_translator"
|
||||
)
|
||||
def test_translate_text_with_source_language(self, mock_get_translator, provider):
|
||||
"""Test translation with explicit source language."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.return_value = "Bonjour"
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
request = TranslationRequest(
|
||||
text="Hello", target_language="fr", source_language="en"
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
mock_get_translator.assert_called_once_with("en", "fr")
|
||||
assert response.translated_text == "Bonjour"
|
||||
|
||||
@patch(
|
||||
"services.providers.google_provider.GoogleTranslationProvider._get_translator"
|
||||
)
|
||||
def test_translate_text_error_fallback(self, mock_get_translator, provider):
|
||||
"""Test that translation errors return original text and structured error (Story 2.2)."""
|
||||
mock_get_translator.side_effect = Exception("API Error")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Hello"
|
||||
assert response.provider_name == "google"
|
||||
assert response.error is not None
|
||||
assert response.error_code is not None
|
||||
|
||||
def test_translate_batch_empty(self, provider):
|
||||
"""Test batch translation with empty list."""
|
||||
responses = provider.translate_batch([])
|
||||
assert responses == []
|
||||
|
||||
@patch.object(GoogleTranslationProvider, "translate_text")
|
||||
def test_translate_batch(self, mock_translate, provider):
|
||||
"""Test batch translation."""
|
||||
mock_translate.side_effect = [
|
||||
TranslationResponse(translated_text="Bonjour", provider_name="google"),
|
||||
TranslationResponse(translated_text="Monde", provider_name="google"),
|
||||
]
|
||||
|
||||
requests = [
|
||||
TranslationRequest(text="Hello", target_language="fr"),
|
||||
TranslationRequest(text="World", target_language="fr"),
|
||||
]
|
||||
responses = provider.translate_batch(requests)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert responses[0].translated_text == "Bonjour"
|
||||
assert responses[1].translated_text == "Monde"
|
||||
|
||||
def test_health_check(self, provider):
|
||||
"""Test health check."""
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "google"
|
||||
assert isinstance(status.available, bool)
|
||||
assert status.latency_ms is not None
|
||||
|
||||
def test_get_google_provider_singleton(self):
|
||||
"""Test that get_google_provider returns same instance."""
|
||||
provider1 = get_google_provider()
|
||||
provider2 = get_google_provider()
|
||||
|
||||
assert provider1 is provider2
|
||||
|
||||
|
||||
class TestGoogleProviderCaching:
|
||||
"""Tests for Google provider caching functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cache(self):
|
||||
"""Create a mock cache."""
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
return cache
|
||||
|
||||
def test_cache_hit(self, mock_cache):
|
||||
"""Test that cache hits return cached result."""
|
||||
mock_cache.get.return_value = "Cached Translation"
|
||||
|
||||
provider = GoogleTranslationProvider(use_cache=True)
|
||||
provider._cache = mock_cache
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Cached Translation"
|
||||
assert response.from_cache is True
|
||||
|
||||
@patch(
|
||||
"services.providers.google_provider.GoogleTranslationProvider._get_translator"
|
||||
)
|
||||
def test_cache_set_on_miss(self, mock_get_translator, mock_cache):
|
||||
"""Test that translations are cached on miss."""
|
||||
mock_translator = MagicMock()
|
||||
mock_translator.translate.return_value = "Bonjour"
|
||||
mock_get_translator.return_value = mock_translator
|
||||
|
||||
provider = GoogleTranslationProvider(use_cache=True)
|
||||
provider._cache = mock_cache
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
provider.translate_text(request)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"Hello", "fr", "auto", "google", "Bonjour"
|
||||
)
|
||||
493
tests/test_providers/test_ollama_provider.py
Normal file
493
tests/test_providers/test_ollama_provider.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
Tests for the OllamaTranslationProvider.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError
|
||||
|
||||
from services.providers.ollama_provider import (
|
||||
OllamaTranslationProvider,
|
||||
OllamaProviderError,
|
||||
get_ollama_provider,
|
||||
register_ollama_provider,
|
||||
_build_system_prompt,
|
||||
_get_language_name,
|
||||
OLLAMA_UNAVAILABLE,
|
||||
OLLAMA_MODEL_NOT_FOUND,
|
||||
OLLAMA_TIMEOUT,
|
||||
OLLAMA_GENERATION_ERROR,
|
||||
OLLAMA_CONTEXT_TOO_LONG,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
|
||||
|
||||
class TestOllamaProviderError:
|
||||
"""Tests for OllamaProviderError exception."""
|
||||
|
||||
def test_error_creation(self):
|
||||
"""Test error creation with all fields."""
|
||||
error = OllamaProviderError(
|
||||
code=OLLAMA_UNAVAILABLE,
|
||||
message="Ollama unavailable",
|
||||
details={"provider": "ollama"},
|
||||
)
|
||||
|
||||
assert error.code == OLLAMA_UNAVAILABLE
|
||||
assert error.message == "Ollama unavailable"
|
||||
assert error.details == {"provider": "ollama"}
|
||||
|
||||
def test_error_to_dict(self):
|
||||
"""Test error serialization."""
|
||||
error = OllamaProviderError(
|
||||
code=OLLAMA_MODEL_NOT_FOUND,
|
||||
message="Model not found",
|
||||
details={"model": "llama3"},
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == OLLAMA_MODEL_NOT_FOUND
|
||||
assert result["message"] == "Model not found"
|
||||
assert result["details"]["model"] == "llama3"
|
||||
|
||||
def test_error_to_dict_no_details(self):
|
||||
"""Test error serialization without details."""
|
||||
error = OllamaProviderError(
|
||||
code=OLLAMA_GENERATION_ERROR,
|
||||
message="Generation error",
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == OLLAMA_GENERATION_ERROR
|
||||
assert result["message"] == "Generation error"
|
||||
assert "details" not in result
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Tests for helper functions."""
|
||||
|
||||
def test_get_language_name_common(self):
|
||||
"""Test language name lookup for common languages."""
|
||||
assert _get_language_name("en") == "English"
|
||||
assert _get_language_name("fr") == "French"
|
||||
assert _get_language_name("es") == "Spanish"
|
||||
assert _get_language_name("de") == "German"
|
||||
assert _get_language_name("zh") == "Chinese"
|
||||
assert _get_language_name("ja") == "Japanese"
|
||||
|
||||
def test_get_language_name_with_variant(self):
|
||||
"""Test language name lookup with variant codes."""
|
||||
assert _get_language_name("en-US") == "English"
|
||||
assert _get_language_name("pt-BR") == "Portuguese"
|
||||
|
||||
def test_get_language_name_unknown(self):
|
||||
"""Test language name lookup for unknown codes."""
|
||||
assert _get_language_name("xx") == "xx"
|
||||
|
||||
def test_build_system_prompt_default(self):
|
||||
"""Test default system prompt generation."""
|
||||
prompt = _build_system_prompt("English", "French")
|
||||
|
||||
assert "English" in prompt
|
||||
assert "French" in prompt
|
||||
assert "translator" in prompt.lower()
|
||||
|
||||
def test_build_system_prompt_custom(self):
|
||||
"""Test custom system prompt."""
|
||||
custom = "Translate this text formally for business context."
|
||||
prompt = _build_system_prompt("English", "French", custom)
|
||||
|
||||
assert prompt == custom
|
||||
|
||||
|
||||
class TestOllamaTranslationProvider:
|
||||
"""Tests for OllamaTranslationProvider."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider instance."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def provider_with_retries(self):
|
||||
"""Create an Ollama provider with retries."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=2,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
|
||||
def test_init(self, provider):
|
||||
"""Test provider initialization."""
|
||||
assert provider._base_url == "http://localhost:11434"
|
||||
assert provider._model == "llama3"
|
||||
assert provider.timeout == 120
|
||||
assert provider._provider_name == "ollama"
|
||||
|
||||
def test_get_name(self, provider):
|
||||
"""Test provider name."""
|
||||
assert provider.get_name() == "ollama"
|
||||
|
||||
def test_translate_text_empty(self, provider):
|
||||
"""Test translating empty text."""
|
||||
request = TranslationRequest(text="", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == ""
|
||||
assert response.provider_name == "ollama"
|
||||
assert response.from_cache is False
|
||||
|
||||
def test_translate_text_whitespace(self, provider):
|
||||
"""Test translating whitespace-only text."""
|
||||
request = TranslationRequest(text=" ", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == " "
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_translate_text_success(self, mock_request, mock_models, provider):
|
||||
"""Test successful translation."""
|
||||
mock_models.return_value = ["llama3", "mistral"]
|
||||
mock_request.return_value = "Bonjour"
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "ollama"
|
||||
assert response.from_cache is False
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_translate_text_with_custom_prompt(
|
||||
self, mock_request, mock_models, provider
|
||||
):
|
||||
"""Test translation with custom system prompt."""
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.return_value = "Bonjour (formal)"
|
||||
|
||||
request = TranslationRequest(
|
||||
text="Hello",
|
||||
target_language="fr",
|
||||
metadata={"custom_prompt": "Translate formally for business"},
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour (formal)"
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
assert "Translate formally for business" in call_args[0][1]
|
||||
|
||||
def test_translate_batch_empty(self, provider):
|
||||
"""Test batch translation with empty list."""
|
||||
responses = provider.translate_batch([])
|
||||
assert responses == []
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "translate_text")
|
||||
def test_translate_batch(self, mock_translate, provider):
|
||||
"""Test batch translation."""
|
||||
mock_translate.side_effect = [
|
||||
TranslationResponse(translated_text="Bonjour", provider_name="ollama"),
|
||||
TranslationResponse(translated_text="Monde", provider_name="ollama"),
|
||||
]
|
||||
|
||||
requests = [
|
||||
TranslationRequest(text="Hello", target_language="fr"),
|
||||
TranslationRequest(text="World", target_language="fr"),
|
||||
]
|
||||
responses = provider.translate_batch(requests)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert responses[0].translated_text == "Bonjour"
|
||||
assert responses[1].translated_text == "Monde"
|
||||
|
||||
|
||||
class TestOllamaErrorCodes:
|
||||
"""Tests for Ollama error code handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider with no retries for error testing."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
def test_context_too_long_error(self, provider):
|
||||
"""Test context too long error."""
|
||||
long_text = "x" * 130000
|
||||
request = TranslationRequest(text=long_text, target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_CONTEXT_TOO_LONG
|
||||
assert response.error is not None
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
def test_model_not_found_error(self, mock_models, provider):
|
||||
"""Test model not found error."""
|
||||
mock_models.return_value = ["mistral", "qwen2"]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_MODEL_NOT_FOUND
|
||||
assert "llama3" in response.error
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_check_model_available")
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_unavailable_error(self, mock_requests, mock_model_available, provider):
|
||||
"""Test Ollama unavailable error."""
|
||||
mock_model_available.return_value = True
|
||||
mock_requests.post.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_UNAVAILABLE
|
||||
assert "indisponible" in response.error.lower()
|
||||
|
||||
|
||||
class TestOllamaHealthCheck:
|
||||
"""Tests for Ollama health check functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider instance."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
)
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_available(self, mock_requests, provider):
|
||||
"""Test health check when Ollama is available."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"models": [{"name": "llama3"}, {"name": "mistral"}]
|
||||
}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "ollama"
|
||||
assert status.available is True
|
||||
assert status.latency_ms is not None
|
||||
assert status.model == "llama3"
|
||||
assert status.model_available is True
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_model_not_pulled(self, mock_requests, provider):
|
||||
"""Test health check when model is not pulled."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"models": [{"name": "mistral"}]}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is False
|
||||
assert "llama3" in status.error
|
||||
assert status.model == "llama3"
|
||||
assert status.model_available is False
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_unavailable(self, mock_requests, provider):
|
||||
"""Test health check when Ollama is unavailable."""
|
||||
mock_requests.get.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is False
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_caching(self, mock_requests, provider):
|
||||
"""Test that health check results are cached (no API call when cache valid)."""
|
||||
import time
|
||||
from services.providers.schemas import ProviderHealthStatus
|
||||
|
||||
current_time = time.time()
|
||||
cached_status = ProviderHealthStatus(
|
||||
name="ollama",
|
||||
available=True,
|
||||
latency_ms=50.0,
|
||||
error=None,
|
||||
last_check="2024-01-15T10:00:00Z",
|
||||
)
|
||||
provider._health_cache["health_check"] = {
|
||||
"value": cached_status,
|
||||
"timestamp": current_time,
|
||||
}
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is True
|
||||
mock_requests.get.assert_not_called()
|
||||
|
||||
|
||||
class TestOllamaProviderRetry:
|
||||
"""Tests for Ollama provider retry logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider with retry enabled."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
max_retries=2,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_retry_on_timeout(self, mock_request, mock_models, provider):
|
||||
"""Test that timeout errors trigger retry."""
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.side_effect = [
|
||||
OllamaProviderError(OLLAMA_TIMEOUT, "Timeout"),
|
||||
"Bonjour",
|
||||
]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_no_retry_on_model_not_found(self, mock_request, mock_models, provider):
|
||||
"""Test that model not found errors do not trigger retry."""
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.side_effect = OllamaProviderError(
|
||||
OLLAMA_MODEL_NOT_FOUND, "Model not found"
|
||||
)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
provider.translate_text(request)
|
||||
|
||||
assert mock_request.call_count == 1
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_timeout_returns_ollama_timeout_error(self, mock_request, mock_models):
|
||||
"""Test that timeout without retry returns OLLAMA_TIMEOUT in response."""
|
||||
provider = OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.side_effect = OllamaProviderError(
|
||||
OLLAMA_TIMEOUT,
|
||||
"Délai d'attente Ollama dépassé. Réessayez avec un texte plus court.",
|
||||
)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_TIMEOUT
|
||||
assert response.error is not None
|
||||
assert "Délai" in response.error or "timeout" in response.error.lower()
|
||||
|
||||
|
||||
class TestOllamaProviderSingleton:
|
||||
"""Tests for Ollama provider singleton functions."""
|
||||
|
||||
def test_get_ollama_provider(self):
|
||||
"""Test get_ollama_provider creates instance with config."""
|
||||
import services.providers.ollama_provider as ollama_module
|
||||
|
||||
ollama_module._provider_instance = None
|
||||
|
||||
with patch(
|
||||
"services.providers.config.ProvidersConfig"
|
||||
) as mock_config:
|
||||
mock_config.OLLAMA_BASE_URL = "http://localhost:11434"
|
||||
mock_config.OLLAMA_MODEL = "llama3"
|
||||
mock_config.OLLAMA_TIMEOUT = 120
|
||||
mock_config.OLLAMA_MAX_RETRIES = 2
|
||||
mock_config.OLLAMA_RETRY_DELAY = 2.0
|
||||
|
||||
provider = ollama_module.get_ollama_provider()
|
||||
|
||||
assert provider is not None
|
||||
assert provider._model == "llama3"
|
||||
|
||||
ollama_module._provider_instance = None
|
||||
|
||||
|
||||
class TestOllamaRegistryIntegration:
|
||||
"""Tests for Ollama provider registry integration."""
|
||||
|
||||
def test_register_ollama_provider(self):
|
||||
"""Test provider registration."""
|
||||
from services.providers.registry import registry
|
||||
|
||||
registry.unregister("ollama")
|
||||
|
||||
with patch(
|
||||
"services.providers.ollama_provider.get_ollama_provider"
|
||||
) as mock_get:
|
||||
mock_provider = MagicMock()
|
||||
mock_get.return_value = mock_provider
|
||||
|
||||
result = register_ollama_provider()
|
||||
|
||||
assert result == mock_provider
|
||||
assert "ollama" in registry
|
||||
registry.unregister("ollama")
|
||||
|
||||
|
||||
class TestOllamaModelCheck:
|
||||
"""Tests for Ollama model availability checking."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider instance."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
)
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_fetch_available_models(self, mock_requests, provider):
|
||||
"""Test fetching available models."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"models": [
|
||||
{"name": "llama3:latest"},
|
||||
{"name": "mistral:latest"},
|
||||
]
|
||||
}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
models = provider._fetch_available_models()
|
||||
|
||||
assert "llama3:latest" in models
|
||||
assert "mistral:latest" in models
|
||||
|
||||
def test_check_model_available(self, provider):
|
||||
"""Test model availability checking."""
|
||||
import time
|
||||
|
||||
provider._available_models = ["llama3:latest", "mistral:latest"]
|
||||
provider._models_cache_time = time.time()
|
||||
|
||||
assert provider._check_model_available("llama3") is True
|
||||
assert provider._check_model_available("mistral") is True
|
||||
assert provider._check_model_available("qwen2") is False
|
||||
728
tests/test_providers/test_openai_provider.py
Normal file
728
tests/test_providers/test_openai_provider.py
Normal file
@@ -0,0 +1,728 @@
|
||||
"""
|
||||
Tests for the OpenAITranslationProvider.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError
|
||||
|
||||
from services.providers.openai_provider import (
|
||||
OpenAITranslationProvider,
|
||||
OpenAIProviderError,
|
||||
get_openai_provider,
|
||||
register_openai_provider,
|
||||
reset_openai_provider,
|
||||
_build_system_prompt,
|
||||
_get_language_name,
|
||||
OPENAI_RATE_LIMITED,
|
||||
OPENAI_INVALID_KEY,
|
||||
OPENAI_QUOTA_EXCEEDED,
|
||||
OPENAI_TIMEOUT,
|
||||
OPENAI_SERVICE_ERROR,
|
||||
OPENAI_CONTEXT_TOO_LONG,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
|
||||
|
||||
class TestOpenAIProviderError:
|
||||
"""Tests for OpenAIProviderError exception."""
|
||||
|
||||
def test_error_creation(self):
|
||||
"""Test error creation with all fields."""
|
||||
error = OpenAIProviderError(
|
||||
code=OPENAI_RATE_LIMITED,
|
||||
message="Rate limited",
|
||||
details={"retry_after": 20},
|
||||
)
|
||||
|
||||
assert error.code == OPENAI_RATE_LIMITED
|
||||
assert error.message == "Rate limited"
|
||||
assert error.details == {"retry_after": 20}
|
||||
|
||||
def test_error_to_dict(self):
|
||||
"""Test error serialization."""
|
||||
error = OpenAIProviderError(
|
||||
code=OPENAI_INVALID_KEY,
|
||||
message="Invalid key",
|
||||
details={"provider": "openai"},
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == OPENAI_INVALID_KEY
|
||||
assert result["message"] == "Invalid key"
|
||||
assert result["details"]["provider"] == "openai"
|
||||
|
||||
def test_error_to_dict_no_details(self):
|
||||
"""Test error serialization without details."""
|
||||
error = OpenAIProviderError(
|
||||
code=OPENAI_SERVICE_ERROR,
|
||||
message="Service error",
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == OPENAI_SERVICE_ERROR
|
||||
assert result["message"] == "Service error"
|
||||
assert "details" not in result
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Tests for helper functions."""
|
||||
|
||||
def test_get_language_name_common(self):
|
||||
"""Test language name lookup for common languages."""
|
||||
assert _get_language_name("en") == "English"
|
||||
assert _get_language_name("fr") == "French"
|
||||
assert _get_language_name("es") == "Spanish"
|
||||
assert _get_language_name("de") == "German"
|
||||
assert _get_language_name("zh") == "Chinese"
|
||||
assert _get_language_name("ja") == "Japanese"
|
||||
|
||||
def test_get_language_name_with_variant(self):
|
||||
"""Test language name lookup with variant codes."""
|
||||
assert _get_language_name("en-US") == "English"
|
||||
assert _get_language_name("pt-BR") == "Portuguese"
|
||||
|
||||
def test_get_language_name_unknown(self):
|
||||
"""Test language name lookup for unknown codes."""
|
||||
assert _get_language_name("xx") == "xx"
|
||||
|
||||
def test_build_system_prompt_default(self):
|
||||
"""Test default system prompt generation."""
|
||||
prompt = _build_system_prompt("English", "French")
|
||||
|
||||
assert "English" in prompt
|
||||
assert "French" in prompt
|
||||
assert "translator" in prompt.lower()
|
||||
|
||||
def test_build_system_prompt_custom(self):
|
||||
"""Test custom system prompt."""
|
||||
custom = "Translate this text formally for business context."
|
||||
prompt = _build_system_prompt("English", "French", custom)
|
||||
|
||||
assert prompt == custom
|
||||
|
||||
|
||||
class TestOpenAITranslationProvider:
|
||||
"""Tests for OpenAITranslationProvider."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an OpenAI provider instance."""
|
||||
return OpenAITranslationProvider(
|
||||
api_key="test-api-key",
|
||||
model="gpt-4o-mini",
|
||||
timeout=60,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def provider_with_retries(self):
|
||||
"""Create an OpenAI provider with retries."""
|
||||
return OpenAITranslationProvider(
|
||||
api_key="test-api-key",
|
||||
model="gpt-4o-mini",
|
||||
timeout=60,
|
||||
max_retries=2,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
|
||||
def test_init(self, provider):
|
||||
"""Test provider initialization."""
|
||||
assert provider._api_key == "test-api-key"
|
||||
assert provider._model == "gpt-4o-mini"
|
||||
assert provider._base_url == "https://api.openai.com/v1"
|
||||
assert provider._timeout == 60
|
||||
assert provider._provider_name == "openai"
|
||||
|
||||
def test_init_with_custom_base_url(self):
|
||||
"""Test provider initialization with custom base URL."""
|
||||
provider = OpenAITranslationProvider(
|
||||
api_key="test-api-key",
|
||||
model="gpt-4",
|
||||
base_url="https://custom.openai.com/v1",
|
||||
)
|
||||
assert provider._base_url == "https://custom.openai.com/v1"
|
||||
|
||||
def test_get_name(self, provider):
|
||||
"""Test provider name."""
|
||||
assert provider.get_name() == "openai"
|
||||
|
||||
def test_translate_text_empty(self, provider):
|
||||
"""Test translating empty text."""
|
||||
request = TranslationRequest(text="", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == ""
|
||||
assert response.provider_name == "openai"
|
||||
assert response.from_cache is False
|
||||
|
||||
def test_translate_text_whitespace(self, provider):
|
||||
"""Test translating whitespace-only text."""
|
||||
request = TranslationRequest(text=" ", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == " "
|
||||
|
||||
@patch("requests.post")
|
||||
def test_translate_text_success(self, mock_post, provider):
|
||||
"""Test successful translation."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "chatcmpl-123",
|
||||
"choices": [{"message": {"content": "Bonjour"}}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "openai"
|
||||
assert response.from_cache is False
|
||||
|
||||
@patch("requests.post")
|
||||
def test_translate_text_with_custom_prompt(self, mock_post, provider):
|
||||
"""Test translation with custom system prompt."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "chatcmpl-123",
|
||||
"choices": [{"message": {"content": "Bonjour (formal)"}}],
|
||||
"usage": {"total_tokens": 15},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(
|
||||
text="Hello",
|
||||
target_language="fr",
|
||||
metadata={"custom_prompt": "Translate formally for business"},
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour (formal)"
|
||||
# Verify custom prompt was used in API call
|
||||
call_args = mock_post.call_args
|
||||
assert (
|
||||
"Translate formally for business"
|
||||
in call_args[1]["json"]["messages"][0]["content"]
|
||||
)
|
||||
|
||||
def test_translate_batch_empty(self, provider):
|
||||
"""Test batch translation with empty list."""
|
||||
responses = provider.translate_batch([])
|
||||
assert responses == []
|
||||
|
||||
@patch.object(OpenAITranslationProvider, "translate_text")
|
||||
def test_translate_batch(self, mock_translate, provider):
|
||||
"""Test batch translation."""
|
||||
mock_translate.side_effect = [
|
||||
TranslationResponse(translated_text="Bonjour", provider_name="openai"),
|
||||
TranslationResponse(translated_text="Au revoir", provider_name="openai"),
|
||||
]
|
||||
|
||||
requests = [
|
||||
TranslationRequest(text="Hello", target_language="fr"),
|
||||
TranslationRequest(text="Goodbye", target_language="fr"),
|
||||
]
|
||||
responses = provider.translate_batch(requests)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert responses[0].translated_text == "Bonjour"
|
||||
assert responses[1].translated_text == "Au revoir"
|
||||
|
||||
|
||||
class TestOpenAIErrorHandling:
|
||||
"""Tests for OpenAI error handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
timeout=60,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_rate_limit_error(self, mock_post, provider):
|
||||
"""Test rate limit error handling."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 429
|
||||
mock_response.json.return_value = {
|
||||
"error": {"code": "rate_limit_exceeded", "message": "Rate limit exceeded"}
|
||||
}
|
||||
mock_response.headers = {"retry-after": "20"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_RATE_LIMITED
|
||||
assert "20" in response.error or "Limite" in response.error
|
||||
|
||||
@patch("requests.post")
|
||||
def test_invalid_api_key_error(self, mock_post, provider):
|
||||
"""Test invalid API key error handling."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_response.json.return_value = {
|
||||
"error": {"code": "invalid_api_key", "message": "Invalid API key"}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_INVALID_KEY
|
||||
assert "invalide" in response.error.lower() or "Invalid" in response.error
|
||||
|
||||
@patch("requests.post")
|
||||
def test_quota_exceeded_error(self, mock_post, provider):
|
||||
"""Test quota exceeded error handling."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 429
|
||||
mock_response.json.return_value = {
|
||||
"error": {
|
||||
"code": "insufficient_quota",
|
||||
"message": "You exceeded your current quota",
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_QUOTA_EXCEEDED
|
||||
assert "quota" in response.error.lower() or "Quota" in response.error
|
||||
|
||||
@patch("requests.post")
|
||||
def test_context_too_long_error(self, mock_post, provider):
|
||||
"""Test context length exceeded error."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.json.return_value = {
|
||||
"error": {
|
||||
"code": "context_length_exceeded",
|
||||
"message": "This model's maximum context length is 4097 tokens",
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_CONTEXT_TOO_LONG
|
||||
assert "trop long" in response.error.lower() or "long" in response.error.lower()
|
||||
|
||||
@patch("requests.post")
|
||||
def test_service_error(self, mock_post, provider):
|
||||
"""Test OpenAI service error (500)."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.json.return_value = {
|
||||
"error": {"code": "server_error", "message": "The server had an error"}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_SERVICE_ERROR
|
||||
assert response.error is not None
|
||||
|
||||
@patch("requests.post")
|
||||
def test_timeout_error(self, mock_post, provider):
|
||||
"""Test timeout error handling."""
|
||||
mock_post.side_effect = Timeout("Request timed out")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_TIMEOUT
|
||||
assert "délai" in response.error.lower() or "timeout" in response.error.lower()
|
||||
|
||||
@patch("requests.post")
|
||||
def test_connection_error(self, mock_post, provider):
|
||||
"""Test connection error handling."""
|
||||
mock_post.side_effect = RequestsConnectionError("Connection failed")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
# Connection errors are mapped to service error
|
||||
assert response.error is not None
|
||||
assert response.error_code is not None
|
||||
|
||||
|
||||
class TestOpenAIRetryLogic:
|
||||
"""Tests for retry logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider_with_retries(self):
|
||||
return OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
timeout=60,
|
||||
max_retries=2,
|
||||
retry_delay=0.01, # Fast for testing
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_retry_on_rate_limit(self, mock_post, provider_with_retries):
|
||||
"""Test retry on rate limit error."""
|
||||
# First call fails with rate limit, second succeeds
|
||||
error_response = MagicMock()
|
||||
error_response.status_code = 429
|
||||
error_response.json.return_value = {
|
||||
"error": {"code": "rate_limit_exceeded", "message": "Rate limited"}
|
||||
}
|
||||
error_response.headers = {}
|
||||
|
||||
success_response = MagicMock()
|
||||
success_response.status_code = 200
|
||||
success_response.json.return_value = {
|
||||
"id": "chatcmpl-123",
|
||||
"choices": [{"message": {"content": "Bonjour"}}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
|
||||
mock_post.side_effect = [error_response, success_response]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider_with_retries.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.error is None
|
||||
assert mock_post.call_count == 2
|
||||
|
||||
@patch("requests.post")
|
||||
def test_retry_exhausted(self, mock_post, provider_with_retries):
|
||||
"""Test that retry eventually gives up."""
|
||||
error_response = MagicMock()
|
||||
error_response.status_code = 429
|
||||
error_response.json.return_value = {
|
||||
"error": {"code": "rate_limit_exceeded", "message": "Rate limited"}
|
||||
}
|
||||
error_response.headers = {}
|
||||
|
||||
mock_post.return_value = error_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider_with_retries.translate_text(request)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error_code == OPENAI_RATE_LIMITED
|
||||
assert mock_post.call_count == 3 # Initial + 2 retries
|
||||
|
||||
|
||||
class TestOpenAIHealthCheck:
|
||||
"""Tests for health check functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
@patch("requests.get")
|
||||
def test_is_available_success(self, mock_get, provider):
|
||||
"""Test is_available when API is reachable."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"data": [{"id": "gpt-4"}]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
assert provider.is_available() is True
|
||||
|
||||
@patch("requests.get")
|
||||
def test_is_available_failure(self, mock_get, provider):
|
||||
"""Test is_available when API is unreachable."""
|
||||
mock_get.side_effect = RequestsConnectionError("Connection failed")
|
||||
|
||||
assert provider.is_available() is False
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_success(self, mock_get, provider):
|
||||
"""Test health check success."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"data": [{"id": "gpt-4o-mini"}]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "openai"
|
||||
assert status.available is True
|
||||
assert status.latency_ms is not None
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_failure(self, mock_get, provider):
|
||||
"""Test health check failure."""
|
||||
mock_get.side_effect = RequestsConnectionError("Connection failed")
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "openai"
|
||||
assert status.available is False
|
||||
assert status.error is not None
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_caching(self, mock_get, provider):
|
||||
"""Test that health check results are cached."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"data": []}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# First call should hit the API
|
||||
provider.health_check()
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
# Second call should use cache
|
||||
provider.health_check()
|
||||
assert mock_get.call_count == 1 # No additional call
|
||||
|
||||
|
||||
class TestOpenAIRegistryIntegration:
|
||||
"""Tests for registry integration."""
|
||||
|
||||
def test_register_openai_provider(self):
|
||||
"""Test provider registration."""
|
||||
from services.providers.registry import registry
|
||||
|
||||
registry.unregister("openai")
|
||||
|
||||
with patch(
|
||||
"services.providers.openai_provider.get_openai_provider"
|
||||
) as mock_get:
|
||||
mock_provider = MagicMock()
|
||||
mock_get.return_value = mock_provider
|
||||
|
||||
result = register_openai_provider()
|
||||
|
||||
assert result == mock_provider
|
||||
assert "openai" in registry
|
||||
registry.unregister("openai")
|
||||
|
||||
def test_get_openai_provider_singleton(self):
|
||||
"""Test that get_openai_provider returns a singleton."""
|
||||
import services.providers.openai_provider as openai_module
|
||||
|
||||
# Reset singleton
|
||||
openai_module._provider_instance = None
|
||||
|
||||
# Create first provider directly without mocking
|
||||
# (singleton pattern is simple enough to test directly)
|
||||
provider1 = OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Manually set the singleton
|
||||
openai_module._provider_instance = provider1
|
||||
|
||||
# Second call should return same instance
|
||||
provider2 = get_openai_provider()
|
||||
|
||||
assert provider1 is provider2
|
||||
|
||||
# Reset singleton after test
|
||||
openai_module._provider_instance = None
|
||||
|
||||
def test_reset_openai_provider(self):
|
||||
"""Test reset_openai_provider clears the singleton."""
|
||||
import services.providers.openai_provider as openai_module
|
||||
|
||||
# Set up a singleton
|
||||
openai_module._provider_instance = OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Verify it's set
|
||||
assert openai_module._provider_instance is not None
|
||||
|
||||
# Reset
|
||||
reset_openai_provider()
|
||||
|
||||
# Verify it's cleared
|
||||
assert openai_module._provider_instance is None
|
||||
|
||||
|
||||
class TestOpenAIValidation:
|
||||
"""Tests for input validation."""
|
||||
|
||||
def test_empty_api_key_raises_error(self):
|
||||
"""Test that empty API key raises ValueError."""
|
||||
with pytest.raises(ValueError, match="API key cannot be empty"):
|
||||
OpenAITranslationProvider(api_key="")
|
||||
|
||||
def test_whitespace_api_key_raises_error(self):
|
||||
"""Test that whitespace-only API key raises ValueError."""
|
||||
with pytest.raises(ValueError, match="API key cannot be empty"):
|
||||
OpenAITranslationProvider(api_key=" ")
|
||||
|
||||
def test_text_too_long_preemptive_check(self):
|
||||
"""Test preemptive check for text exceeding token limit."""
|
||||
provider = OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
# Create text longer than 16000 chars (~4000 tokens)
|
||||
long_text = "x" * 17000
|
||||
request = TranslationRequest(text=long_text, target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_CONTEXT_TOO_LONG
|
||||
assert response.error is not None
|
||||
assert "trop long" in response.error.lower()
|
||||
|
||||
|
||||
class TestOpenAIMalformedResponses:
|
||||
"""Tests for malformed API response handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
timeout=60,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_empty_choices_array(self, mock_post, provider):
|
||||
"""Test handling of empty choices array."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "chatcmpl-123",
|
||||
"choices": [],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_SERVICE_ERROR
|
||||
assert "vide" in response.error.lower()
|
||||
|
||||
@patch("requests.post")
|
||||
def test_missing_message_content(self, mock_post, provider):
|
||||
"""Test handling of missing message content."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "chatcmpl-123",
|
||||
"choices": [{"message": {}}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_SERVICE_ERROR
|
||||
|
||||
@patch("requests.post")
|
||||
def test_missing_message_key(self, mock_post, provider):
|
||||
"""Test handling of missing message key in choice."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "chatcmpl-123",
|
||||
"choices": [{"finish_reason": "stop"}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_SERVICE_ERROR
|
||||
|
||||
@patch("requests.post")
|
||||
def test_empty_content_string(self, mock_post, provider):
|
||||
"""Test handling of empty content string."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "chatcmpl-123",
|
||||
"choices": [{"message": {"content": ""}}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OPENAI_SERVICE_ERROR
|
||||
|
||||
|
||||
class TestOpenAIHealthCheckModelInfo:
|
||||
"""Tests for health check model info."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return OpenAITranslationProvider(
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
timeout=60,
|
||||
health_check_timeout=5,
|
||||
)
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_includes_model(self, mock_get, provider):
|
||||
"""Test that health check includes model info."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": [
|
||||
{"id": "gpt-4o-mini"},
|
||||
{"id": "gpt-4"},
|
||||
]
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.model == "gpt-4o-mini"
|
||||
assert status.model_available is True
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_model_not_available(self, mock_get, provider):
|
||||
"""Test health check when configured model not in list."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": [
|
||||
{"id": "gpt-4"},
|
||||
{"id": "gpt-3.5-turbo"},
|
||||
]
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.model == "gpt-4o-mini"
|
||||
assert status.model_available is False
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_unavailable_includes_model(self, mock_get, provider):
|
||||
"""Test that health check includes model even when unavailable."""
|
||||
mock_get.side_effect = RequestsConnectionError("Connection failed")
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is False
|
||||
assert status.model == "gpt-4o-mini"
|
||||
assert status.model_available is False
|
||||
224
tests/test_providers/test_registry.py
Normal file
224
tests/test_providers/test_registry.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Tests for the ProviderRegistry singleton.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import threading
|
||||
|
||||
from services.providers.registry import ProviderRegistry, get_registry
|
||||
from services.providers.base import TranslationProvider
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
|
||||
|
||||
class MockProvider(TranslationProvider):
|
||||
"""Mock provider for testing."""
|
||||
|
||||
def __init__(self, name: str, available: bool = True):
|
||||
self._name = name
|
||||
self._available = available
|
||||
|
||||
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
|
||||
return TranslationResponse(
|
||||
translated_text=f"[{self._name}] {request.text}",
|
||||
provider_name=self._name,
|
||||
from_cache=False,
|
||||
)
|
||||
|
||||
def get_name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
|
||||
class TestProviderRegistry:
|
||||
"""Tests for the ProviderRegistry class."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_registry(self):
|
||||
"""Clear registry before each test."""
|
||||
registry = get_registry()
|
||||
registry.clear()
|
||||
yield
|
||||
registry.clear()
|
||||
|
||||
def test_singleton_pattern(self):
|
||||
"""Test that ProviderRegistry is a singleton."""
|
||||
registry1 = ProviderRegistry()
|
||||
registry2 = ProviderRegistry()
|
||||
|
||||
assert registry1 is registry2
|
||||
|
||||
def test_get_registry_function(self):
|
||||
"""Test get_registry returns the singleton."""
|
||||
registry1 = get_registry()
|
||||
registry2 = get_registry()
|
||||
|
||||
assert registry1 is registry2
|
||||
|
||||
def test_register_and_get(self):
|
||||
"""Test registering and retrieving a provider."""
|
||||
registry = get_registry()
|
||||
provider = MockProvider("test")
|
||||
|
||||
registry.register("test", provider)
|
||||
retrieved = registry.get("test")
|
||||
|
||||
assert retrieved is provider
|
||||
assert retrieved.get_name() == "test"
|
||||
|
||||
def test_get_nonexistent_provider(self):
|
||||
"""Test getting a provider that doesn't exist."""
|
||||
registry = get_registry()
|
||||
|
||||
result = registry.get("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_unregister(self):
|
||||
"""Test unregistering a provider."""
|
||||
registry = get_registry()
|
||||
provider = MockProvider("test")
|
||||
|
||||
registry.register("test", provider)
|
||||
assert registry.get("test") is not None
|
||||
|
||||
result = registry.unregister("test")
|
||||
|
||||
assert result is True
|
||||
assert registry.get("test") is None
|
||||
|
||||
def test_unregister_nonexistent(self):
|
||||
"""Test unregistering a provider that doesn't exist."""
|
||||
registry = get_registry()
|
||||
|
||||
result = registry.unregister("nonexistent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_list_all(self):
|
||||
"""Test listing all registered providers."""
|
||||
registry = get_registry()
|
||||
registry.register("google", MockProvider("google"))
|
||||
registry.register("deepl", MockProvider("deepl"))
|
||||
registry.register("openai", MockProvider("openai"))
|
||||
|
||||
names = registry.list_all()
|
||||
|
||||
assert len(names) == 3
|
||||
assert "google" in names
|
||||
assert "deepl" in names
|
||||
assert "openai" in names
|
||||
|
||||
def test_list_available(self):
|
||||
"""Test listing only available providers."""
|
||||
registry = get_registry()
|
||||
registry.register("available1", MockProvider("available1", available=True))
|
||||
registry.register("available2", MockProvider("available2", available=True))
|
||||
registry.register("unavailable", MockProvider("unavailable", available=False))
|
||||
|
||||
names = registry.list_available()
|
||||
|
||||
assert len(names) == 2
|
||||
assert "available1" in names
|
||||
assert "available2" in names
|
||||
assert "unavailable" not in names
|
||||
|
||||
def test_get_first_available(self):
|
||||
"""Test getting first available provider from a list."""
|
||||
registry = get_registry()
|
||||
registry.register("google", MockProvider("google", available=True))
|
||||
registry.register("deepl", MockProvider("deepl", available=True))
|
||||
registry.register("openai", MockProvider("openai", available=False))
|
||||
|
||||
provider = registry.get_first_available(["openai", "deepl", "google"])
|
||||
|
||||
assert provider is not None
|
||||
assert provider.get_name() == "deepl"
|
||||
|
||||
def test_get_first_available_all_unavailable(self):
|
||||
"""Test getting first available when all are unavailable."""
|
||||
registry = get_registry()
|
||||
registry.register("google", MockProvider("google", available=False))
|
||||
registry.register("deepl", MockProvider("deepl", available=False))
|
||||
|
||||
provider = registry.get_first_available(["google", "deepl"])
|
||||
|
||||
assert provider is None
|
||||
|
||||
def test_get_first_available_not_registered(self):
|
||||
"""Test getting first available when provider not registered."""
|
||||
registry = get_registry()
|
||||
|
||||
provider = registry.get_first_available(["google", "deepl"])
|
||||
|
||||
assert provider is None
|
||||
|
||||
def test_clear(self):
|
||||
"""Test clearing all providers."""
|
||||
registry = get_registry()
|
||||
registry.register("google", MockProvider("google"))
|
||||
registry.register("deepl", MockProvider("deepl"))
|
||||
|
||||
registry.clear()
|
||||
|
||||
assert len(registry) == 0
|
||||
assert registry.list_all() == []
|
||||
|
||||
def test_len(self):
|
||||
"""Test __len__ method."""
|
||||
registry = get_registry()
|
||||
|
||||
assert len(registry) == 0
|
||||
|
||||
registry.register("google", MockProvider("google"))
|
||||
assert len(registry) == 1
|
||||
|
||||
registry.register("deepl", MockProvider("deepl"))
|
||||
assert len(registry) == 2
|
||||
|
||||
def test_contains(self):
|
||||
"""Test __contains__ method."""
|
||||
registry = get_registry()
|
||||
registry.register("google", MockProvider("google"))
|
||||
|
||||
assert "google" in registry
|
||||
assert "deepl" not in registry
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""Test that registry operations are thread-safe."""
|
||||
registry = get_registry()
|
||||
registry.clear()
|
||||
errors = []
|
||||
|
||||
def register_providers(prefix: str, count: int):
|
||||
try:
|
||||
for i in range(count):
|
||||
registry.register(f"{prefix}_{i}", MockProvider(f"{prefix}_{i}"))
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=register_providers, args=(f"thread_{t}", 10))
|
||||
for t in range(5)
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0
|
||||
assert len(registry) == 50
|
||||
|
||||
def test_overwrite_registration(self):
|
||||
"""Test that registering with same name overwrites."""
|
||||
registry = get_registry()
|
||||
provider1 = MockProvider("test1")
|
||||
provider2 = MockProvider("test2")
|
||||
|
||||
registry.register("test", provider1)
|
||||
assert registry.get("test").get_name() == "test1"
|
||||
|
||||
registry.register("test", provider2)
|
||||
assert registry.get("test").get_name() == "test2"
|
||||
Reference in New Issue
Block a user