Files
office_translator/tests/test_providers/test_fallback.py
Sepehr Ramezani 26bd096a06 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>
2026-04-25 15:01:47 +02:00

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"