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:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -0,0 +1 @@
"""Tests for translation providers package."""

View 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__

View 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()

View 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"

View 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

View 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"
)

View 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

View 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

View 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"