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