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>
489 lines
18 KiB
Python
489 lines
18 KiB
Python
"""
|
|
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()
|