""" Tests for the DeepSeekTranslationProvider. Covers: - defaults (public endpoint ``api.deepseek.com/v1``, model ``deepseek-chat``) - translation success, 401/429 handling, empty-text short-circuit Batch mode (ported from the OpenAI provider): - same language pair + metadata chunk → ONE numbered-JSON request for all texts - unparseable/failed batch answer → per-item fallback, every text still translated - mixed metadata or a single text → no batch (individual requests) """ import json import pytest from unittest.mock import patch, MagicMock from services.providers.deepseek_provider import ( DeepSeekTranslationProvider, DeepSeekProviderError, DEEPSEEK_RATE_LIMITED, DEEPSEEK_INVALID_KEY, DEEPSEEK_TIMEOUT, DEEPSEEK_SERVICE_ERROR, ) from services.providers.schemas import TranslationRequest class TestDeepSeekProviderConfig: """Defaults must point at the real public endpoint.""" def test_default_base_url_is_public_host(self): provider = DeepSeekTranslationProvider(api_key="k", max_retries=0) assert provider._base_url == "https://api.deepseek.com/v1" def test_default_model_is_deepseek_chat(self): provider = DeepSeekTranslationProvider(api_key="k", max_retries=0) assert provider._model == "deepseek-chat" def test_custom_base_url_respected(self): provider = DeepSeekTranslationProvider( api_key="k", base_url="https://proxy.example.com/v1", max_retries=0 ) assert provider._base_url == "https://proxy.example.com/v1" def test_get_name(self): provider = DeepSeekTranslationProvider(api_key="k", max_retries=0) assert provider.get_name() == "deepseek" def test_empty_api_key_raises(self): with pytest.raises(ValueError, match="API key cannot be empty"): DeepSeekTranslationProvider(api_key=" ") class TestDeepSeekTranslateText: @pytest.fixture def provider(self): return DeepSeekTranslationProvider(api_key="k", max_retries=0) def _mock_post(self, payload, status_code=200): mock_response = MagicMock() mock_response.status_code = status_code mock_response.json.return_value = payload mock_response.text = "" return mock_response @patch("requests.post") def test_success(self, mock_post, provider): mock_post.return_value = self._mock_post( {"choices": [{"message": {"content": "Bonjour"}}], "usage": {}} ) resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr")) assert resp.translated_text == "Bonjour" assert resp.provider_name == "deepseek" assert mock_post.call_args[0][0] == "https://api.deepseek.com/v1/chat/completions" def test_empty_text_short_circuits(self, provider): resp = provider.translate_text(TranslationRequest(text="", target_language="fr")) assert resp.translated_text == "" @patch("requests.post") def test_invalid_key_returns_error(self, mock_post, provider): mock_post.return_value = self._mock_post({"error": "bad key"}, status_code=401) resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr")) assert resp.error_code == DEEPSEEK_INVALID_KEY # Original text returned on failure. assert resp.translated_text == "Hello" @patch("time.sleep") @patch("requests.post") def test_rate_limit_then_success(self, mock_post, mock_sleep): provider = DeepSeekTranslationProvider(api_key="k", max_retries=2, retry_delay=0.01) mock_post.side_effect = [ self._mock_post({"error": "slow down"}, status_code=429), self._mock_post({"choices": [{"message": {"content": "Hola"}}], "usage": {}}), ] resp = provider.translate_text(TranslationRequest(text="Hello", target_language="es")) assert resp.translated_text == "Hola" assert mock_sleep.called # backoff happened @patch("requests.post") def test_service_error_when_empty_choices(self, mock_post, provider): mock_post.return_value = self._mock_post({"choices": []}) resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr")) assert resp.error_code == DEEPSEEK_SERVICE_ERROR class TestDeepSeekBatchTranslation: """Batch mode: one numbered-JSON request per same pair/metadata chunk.""" @pytest.fixture def provider(self): return DeepSeekTranslationProvider(api_key="k", max_retries=0) def _mock_post(self, payload, status_code=200): mock_response = MagicMock() mock_response.status_code = status_code mock_response.json.return_value = payload mock_response.text = "" return mock_response def _batch_response(self, translations): """Mock a 200 response whose content is a numbered JSON array.""" content = json.dumps( [{"id": i, "translation": t} for i, t in enumerate(translations)] ) return self._mock_post({"choices": [{"message": {"content": content}}], "usage": {}}) @patch("requests.post") def test_batch_of_three_uses_single_request(self, mock_post, provider): mock_post.return_value = self._batch_response(["Bonjour", "Au revoir", "Merci"]) requests = [ TranslationRequest(text="Hello", target_language="fr"), TranslationRequest(text="Goodbye", target_language="fr"), TranslationRequest(text="Thank you", target_language="fr"), ] responses = provider.translate_batch(requests) assert mock_post.call_count == 1 assert [r.translated_text for r in responses] == ["Bonjour", "Au revoir", "Merci"] assert all(r.provider_name == "deepseek" for r in responses) assert all(r.error is None for r in responses) assert mock_post.call_args[0][0] == "https://api.deepseek.com/v1/chat/completions" @patch("requests.post") def test_batch_request_payload_is_numbered_json_with_instructions(self, mock_post, provider): mock_post.return_value = self._batch_response(["Bonjour", "Au revoir"]) requests = [ TranslationRequest( text="Hello", target_language="fr", source_language="en", metadata={"custom_prompt": "use a formal tone"}, ), TranslationRequest( text="Goodbye", target_language="fr", source_language="en", metadata={"custom_prompt": "use a formal tone"}, ), ] provider.translate_batch(requests) payload = mock_post.call_args[1]["json"] system = payload["messages"][0]["content"] user = payload["messages"][1]["content"] # System prompt = base instructions + custom prompt + strict JSON rule. assert "professional translator" in system assert "ADDITIONAL CONTEXT AND INSTRUCTIONS" in system assert "use a formal tone" in system assert "BATCH MODE" in system assert "ONLY a JSON array" in system # User message = the numbered JSON list of the texts. parsed = json.loads(user) assert [item["text"] for item in parsed] == ["Hello", "Goodbye"] assert [item["id"] for item in parsed] == [0, 1] @patch("requests.post") def test_invalid_batch_json_falls_back_to_individual(self, mock_post, provider): broken_batch = self._mock_post( {"choices": [{"message": {"content": "désolé, pas de JSON ici"}}], "usage": {}} ) individuals = [ self._mock_post({"choices": [{"message": {"content": "Bonjour"}}], "usage": {}}), self._mock_post({"choices": [{"message": {"content": "Au revoir"}}], "usage": {}}), self._mock_post({"choices": [{"message": {"content": "Merci"}}], "usage": {}}), ] mock_post.side_effect = [broken_batch] + individuals requests = [ TranslationRequest(text="Hello", target_language="fr"), TranslationRequest(text="Goodbye", target_language="fr"), TranslationRequest(text="Thank you", target_language="fr"), ] responses = provider.translate_batch(requests) # 1 failed batch attempt + 3 individual calls. assert mock_post.call_count == 4 assert [r.translated_text for r in responses] == ["Bonjour", "Au revoir", "Merci"] assert all(r.error is None for r in responses) @patch("requests.post") def test_batch_api_error_falls_back_to_individual(self, mock_post, provider): # The batch call itself fails (500) — per-item calls still translate everything. failed_batch = self._mock_post({"error": "boom"}, status_code=500) individuals = [ self._mock_post({"choices": [{"message": {"content": "Bonjour"}}], "usage": {}}), self._mock_post({"choices": [{"message": {"content": "Au revoir"}}], "usage": {}}), ] mock_post.side_effect = [failed_batch] + individuals requests = [ TranslationRequest(text="Hello", target_language="fr"), TranslationRequest(text="Goodbye", target_language="fr"), ] responses = provider.translate_batch(requests) assert mock_post.call_count == 3 assert [r.translated_text for r in responses] == ["Bonjour", "Au revoir"] @patch("requests.post") def test_wrong_batch_length_falls_back_to_individual(self, mock_post, provider): # A JSON answer with a missing item is NOT trusted → fallback. short_batch = self._batch_response(["Bonjour", "Au revoir"]) individuals = [ self._mock_post({"choices": [{"message": {"content": "Bonjour"}}], "usage": {}}), self._mock_post({"choices": [{"message": {"content": "Au revoir"}}], "usage": {}}), self._mock_post({"choices": [{"message": {"content": "Merci"}}], "usage": {}}), ] mock_post.side_effect = [short_batch] + individuals requests = [ TranslationRequest(text="Hello", target_language="fr"), TranslationRequest(text="Goodbye", target_language="fr"), TranslationRequest(text="Thank you", target_language="fr"), ] responses = provider.translate_batch(requests) assert mock_post.call_count == 4 assert [r.translated_text for r in responses] == ["Bonjour", "Au revoir", "Merci"] @patch("requests.post") def test_mixed_metadata_skips_batch(self, mock_post, provider): mock_post.side_effect = [ self._mock_post({"choices": [{"message": {"content": "Bonjour"}}], "usage": {}}), self._mock_post({"choices": [{"message": {"content": "Au revoir"}}], "usage": {}}), ] requests = [ TranslationRequest( text="Hello", target_language="fr", metadata={"custom_prompt": "formal"} ), TranslationRequest( text="Goodbye", target_language="fr", metadata={"custom_prompt": "casual"} ), ] responses = provider.translate_batch(requests) # Two individual calls, no numbered-JSON batch attempt. assert mock_post.call_count == 2 assert [r.translated_text for r in responses] == ["Bonjour", "Au revoir"] for call in mock_post.call_args_list: assert call[1]["json"]["messages"][1]["content"] in ("Hello", "Goodbye") @patch("requests.post") def test_mixed_language_pair_skips_batch(self, mock_post, provider): mock_post.side_effect = [ self._mock_post({"choices": [{"message": {"content": "Bonjour"}}], "usage": {}}), self._mock_post({"choices": [{"message": {"content": "Hola"}}], "usage": {}}), ] requests = [ TranslationRequest(text="Hello", target_language="fr"), TranslationRequest(text="Hello", target_language="es"), ] responses = provider.translate_batch(requests) assert mock_post.call_count == 2 assert [r.translated_text for r in responses] == ["Bonjour", "Hola"] @patch("requests.post") def test_single_request_is_individual(self, mock_post, provider): mock_post.return_value = self._mock_post( {"choices": [{"message": {"content": "Bonjour"}}], "usage": {}} ) requests = [TranslationRequest(text="Hello", target_language="fr")] responses = provider.translate_batch(requests) assert mock_post.call_count == 1 assert responses[0].translated_text == "Bonjour" # The user message is the raw text, not a numbered JSON list. user_message = mock_post.call_args[1]["json"]["messages"][1]["content"] assert user_message == "Hello" def test_empty_batch_returns_empty(self, provider): assert provider.translate_batch([]) == [] class TestDeepSeekProviderError: def test_error_carries_code_and_message(self): err = DeepSeekProviderError(DEEPSEEK_TIMEOUT, "timed out", details={"wait": 1}) assert err.code == DEEPSEEK_TIMEOUT assert err.message == "timed out" assert err.details == {"wait": 1} def test_rate_limited_code_exists(self): # Sanity: the retryable codes used by the provider are importable. assert DEEPSEEK_RATE_LIMITED == "DEEPSEEK_RATE_LIMITED"