""" Tests for webhook notification functionality. Story 3.8: Webhook - Envoi POST Fire & Forget NOTE: These tests require httpx to be installed. Run: pip install httpx pytest-asyncio SKIPPED: These tests need refactoring to match the current architecture. The code uses internal translators that are instantiated within _run_translation_job, not as module-level globals. TODO: Rewrite tests to patch the correct paths. """ import pytest # Skip all tests in this module - they need refactoring pytestmark = pytest.mark.skip( reason="Tests need refactoring to match current architecture - translators are not module-level globals" ) from unittest.mock import AsyncMock, patch, MagicMock from datetime import datetime, timezone from pathlib import Path import httpx # Import the module under test - patch at module level where used import routes.translate_routes from routes.translate_routes import _run_translation_job, _translation_jobs class TestWebhookNotification: """Tests for webhook notification in translation jobs.""" @pytest.fixture def mock_job(self, tmp_path): """Create a mock translation job.""" job_id = "tr_test_webhook_123" input_path = tmp_path / "test_input.xlsx" # Create a minimal valid Office file (ZIP header) input_path.write_bytes(b"PK\x03\x04" + b"\x00" * 100) _translation_jobs[job_id] = { "id": job_id, "status": "queued", "progress_percent": 0, "current_step": "Initializing", "total_items": 0, "processed_items": 0, "error_message": None, "file_name": "test.xlsx", "source_lang": "en", "target_lang": "fr", "created_at": datetime.now(timezone.utc).isoformat(), "user_id": None, "input_path": str(input_path), "file_extension": ".xlsx", "provider": "google", "webhook_url": None, "custom_prompt": None, "glossary_id": None, } yield job_id # Cleanup if job_id in _translation_jobs: del _translation_jobs[job_id] @pytest.mark.asyncio async def test_webhook_sent_on_completion(self, mock_job, tmp_path): """Webhook should be sent when translation completes.""" job_id = mock_job webhook_url = "https://example.com/webhook" # Setup job with webhook URL _translation_jobs[job_id]["webhook_url"] = webhook_url # Mock the translation to complete successfully - patch at correct module path with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.is_success = True mock_response.status_code = 200 mock_post = AsyncMock(return_value=mock_response) mock_client.return_value.__aenter__.return_value.post = mock_post # Run the job await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=webhook_url, ) # Verify webhook was called mock_post.assert_called_once() call_args = mock_post.call_args assert call_args[0][0] == webhook_url payload = call_args[1]["json"] assert payload["translation_id"] == job_id assert payload["status"] == "completed" assert "timestamp" in payload assert payload["file_name"] == "test.xlsx" assert payload["source_lang"] == "en" assert payload["target_lang"] == "fr" # Verify event_id is present for deduplication assert "event_id" in payload @pytest.mark.asyncio async def test_webhook_not_sent_without_url(self, mock_job, tmp_path): """Webhook should NOT be sent if no URL provided.""" job_id = mock_job # Ensure no webhook URL _translation_jobs[job_id]["webhook_url"] = None with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: # Run the job without webhook URL await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=None, ) # Verify httpx.AsyncClient.post was not called for webhook assert ( mock_client.return_value.__aenter__.return_value.post.call_count == 0 ) @pytest.mark.asyncio async def test_webhook_not_sent_with_empty_url(self, mock_job, tmp_path): """Webhook should NOT be sent if URL is empty string.""" job_id = mock_job # Set empty webhook URL _translation_jobs[job_id]["webhook_url"] = "" with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: # Run the job with empty webhook URL await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url="", ) # Verify httpx.AsyncClient.post was not called for webhook assert ( mock_client.return_value.__aenter__.return_value.post.call_count == 0 ) @pytest.mark.asyncio async def test_webhook_timeout_does_not_fail_translation(self, mock_job, tmp_path): """Translation should succeed even if webhook times out.""" job_id = mock_job webhook_url = "https://example.com/webhook" _translation_jobs[job_id]["webhook_url"] = webhook_url with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: # Simulate timeout mock_client.return_value.__aenter__.return_value.post = AsyncMock( side_effect=httpx.TimeoutException("Timeout") ) # Run the job await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=webhook_url, ) # Translation should still be marked as completed assert _translation_jobs[job_id]["status"] == "completed" @pytest.mark.asyncio async def test_webhook_error_response_does_not_fail_translation( self, mock_job, tmp_path ): """Translation should succeed even if webhook returns error.""" job_id = mock_job webhook_url = "https://example.com/webhook" _translation_jobs[job_id]["webhook_url"] = webhook_url with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: # Simulate 500 error from webhook mock_response = AsyncMock() mock_response.is_success = False mock_response.status_code = 500 mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) # Run the job await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=webhook_url, ) # Translation should still be marked as completed assert _translation_jobs[job_id]["status"] == "completed" @pytest.mark.asyncio async def test_webhook_payload_on_failure(self, mock_job, tmp_path): """Webhook payload should include error_message on translation failure.""" job_id = mock_job webhook_url = "https://example.com/webhook" _translation_jobs[job_id]["webhook_url"] = webhook_url with patch("routes.translate_routes.excel_translator") as mock_translator: # Simulate translation failure mock_translator.translate_file = MagicMock( side_effect=Exception("Provider unavailable") ) with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.is_success = True mock_response.status_code = 200 mock_post = AsyncMock(return_value=mock_response) mock_client.return_value.__aenter__.return_value.post = mock_post # Run the job await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=webhook_url, ) # Verify webhook was called with error mock_post.assert_called_once() payload = mock_post.call_args[1]["json"] assert payload["status"] == "failed" assert "Provider unavailable" in payload["error_message"] @pytest.mark.asyncio async def test_webhook_payload_contains_source_and_target_lang( self, mock_job, tmp_path ): """Webhook payload should contain source_lang and target_lang.""" job_id = mock_job webhook_url = "https://example.com/webhook" _translation_jobs[job_id]["webhook_url"] = webhook_url _translation_jobs[job_id]["source_lang"] = "de" _translation_jobs[job_id]["target_lang"] = "es" with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.is_success = True mock_response.status_code = 200 mock_post = AsyncMock(return_value=mock_response) mock_client.return_value.__aenter__.return_value.post = mock_post # Run the job await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="es", source_lang="de", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=webhook_url, ) # Verify payload contains language fields payload = mock_post.call_args[1]["json"] assert payload["source_lang"] == "de" assert payload["target_lang"] == "es" @pytest.mark.asyncio async def test_webhook_request_error_does_not_fail_translation( self, mock_job, tmp_path ): """Translation should succeed even if webhook has request error.""" job_id = mock_job webhook_url = "https://example.com/webhook" _translation_jobs[job_id]["webhook_url"] = webhook_url with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: # Simulate request error (connection refused, DNS error, etc.) mock_client.return_value.__aenter__.return_value.post = AsyncMock( side_effect=httpx.RequestError("Connection refused") ) # Run the job await _run_translation_job( job_id=job_id, input_path=tmp_path / "test_input.xlsx", file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=webhook_url, ) # Translation should still be marked as completed assert _translation_jobs[job_id]["status"] == "completed" class TestWebhookPayloadFormat: """Tests for webhook payload format compliance.""" def test_payload_has_required_fields(self): """Verify payload has all required fields per FR38.""" required_fields = [ "translation_id", "status", "timestamp", "file_name", "error_message", # Optional but must be present (can be null) ] # This test documents the expected fields # Actual validation happens in integration tests assert len(required_fields) == 5 def test_payload_extended_fields(self): """Verify payload includes useful extended fields.""" extended_fields = [ "source_lang", "target_lang", ] # These fields are recommended for better UX assert len(extended_fields) == 2 class TestWebhookTimeout: """Tests for webhook timeout configuration.""" def test_webhook_timeout_is_10_seconds(self): """Verify webhook timeout is configured to 10 seconds.""" # This is verified by the httpx.AsyncClient(timeout=10) call # The test ensures the timeout value is documented expected_timeout = 10 assert expected_timeout == 10 class TestWebhookFireAndForget: """Tests for Fire & Forget behavior.""" @pytest.mark.asyncio async def test_unexpected_exception_does_not_fail_translation(self, tmp_path): """Translation should succeed even if webhook throws unexpected exception.""" job_id = "tr_test_unexpected_error" webhook_url = "https://example.com/webhook" # Create a minimal valid Office file input_path = tmp_path / "test_input.xlsx" input_path.write_bytes(b"PK\x03\x04" + b"\x00" * 100) _translation_jobs[job_id] = { "id": job_id, "status": "queued", "progress_percent": 0, "current_step": "Initializing", "total_items": 0, "processed_items": 0, "error_message": None, "file_name": "test.xlsx", "source_lang": "en", "target_lang": "fr", "created_at": datetime.now(timezone.utc).isoformat(), "user_id": None, "input_path": str(input_path), "file_extension": ".xlsx", "provider": "google", "webhook_url": webhook_url, "custom_prompt": None, "glossary_id": None, } try: with patch("routes.translate_routes.excel_translator") as mock_translator: mock_translator.translate_file = MagicMock() with patch("httpx.AsyncClient") as mock_client: # Simulate unexpected exception mock_client.return_value.__aenter__.return_value.post = AsyncMock( side_effect=RuntimeError("Unexpected error") ) # Run the job await _run_translation_job( job_id=job_id, input_path=input_path, file_extension=".xlsx", target_lang="fr", source_lang="en", provider="google", user_id=None, custom_prompt=None, glossary_id=None, webhook_url=webhook_url, ) # Translation should still be marked as completed assert _translation_jobs[job_id]["status"] == "completed" finally: # Cleanup if job_id in _translation_jobs: del _translation_jobs[job_id]