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>
586 lines
22 KiB
Python
586 lines
22 KiB
Python
"""
|
|
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"
|