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