""" Rigorous Tests for Gemini AI Service. Tests cover: 1. Initialization & API key handling 2. Concurrency semaphores 3. Text generation 4. Animation prompt generation 5. Image analysis & editing 6. Video generation, status checking, downloading 7. Error handling """ import pytest import asyncio import os import tempfile from unittest.mock import patch, MagicMock, AsyncMock, PropertyMock from datetime import datetime # ============================================================================= # 1. Initialization & Configuration Tests # ============================================================================= class TestGeminiServiceInit: """Test GeminiService initialization and configuration.""" def test_init_with_explicit_api_key(self): """Service initializes with explicit API key.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService service = GeminiService(api_key="test-key-123") assert service.api_key == "test-key-123" mock_genai.Client.assert_called_once_with(api_key="test-key-123") def test_init_with_env_fallback(self): """Service falls back to environment variable for API key.""" with patch('services.gemini_service.genai') as mock_genai: with patch.dict(os.environ, {"GEMINI_API_KEY": "env-key-456"}): from services.gemini_service import GeminiService service = GeminiService() assert service.api_key == "env-key-456" def test_init_fails_without_api_key(self): """Service raises error when no API key available.""" with patch.dict(os.environ, {}, clear=True): # Remove GEMINI_API_KEY if present os.environ.pop("GEMINI_API_KEY", None) os.environ.pop("GEMINI_API_KEYS", None) from services.gemini_service import get_gemini_api_key with pytest.raises(ValueError, match="Server Authentication Error"): get_gemini_api_key() def test_models_dict_has_required_entries(self): """MODELS dictionary has all required model names.""" from services.gemini_service import MODELS assert "text_generation" in MODELS assert "image_edit" in MODELS assert "video_generation" in MODELS assert all(isinstance(v, str) for v in MODELS.values()) # ============================================================================= # 2. Semaphore Concurrency Tests # ============================================================================= class TestSemaphoreConcurrency: """Test concurrency control via semaphores.""" def test_video_semaphore_respects_limit(self): """Video semaphore uses MAX_CONCURRENT_VIDEOS.""" # Reset global import services.gemini_service as gs gs._video_semaphore = None with patch.object(gs, 'MAX_CONCURRENT_VIDEOS', 3): gs._video_semaphore = None # Reset sem = gs.get_video_semaphore() # Semaphore internal value assert sem._value == 3 def test_image_semaphore_respects_limit(self): """Image semaphore uses MAX_CONCURRENT_IMAGES.""" import services.gemini_service as gs gs._image_semaphore = None with patch.object(gs, 'MAX_CONCURRENT_IMAGES', 5): gs._image_semaphore = None sem = gs.get_image_semaphore() assert sem._value == 5 def test_text_semaphore_respects_limit(self): """Text semaphore uses MAX_CONCURRENT_TEXT.""" import services.gemini_service as gs gs._text_semaphore = None with patch.object(gs, 'MAX_CONCURRENT_TEXT', 10): gs._text_semaphore = None sem = gs.get_text_semaphore() assert sem._value == 10 def test_semaphores_are_singletons(self): """Calling get_*_semaphore multiple times returns same object.""" import services.gemini_service as gs gs._video_semaphore = None gs._image_semaphore = None gs._text_semaphore = None video1 = gs.get_video_semaphore() video2 = gs.get_video_semaphore() assert video1 is video2 image1 = gs.get_image_semaphore() image2 = gs.get_image_semaphore() assert image1 is image2 text1 = gs.get_text_semaphore() text2 = gs.get_text_semaphore() assert text1 is text2 # ============================================================================= # 3. Text Generation Tests # ============================================================================= class TestTextGeneration: """Test generate_text method.""" @pytest.mark.asyncio async def test_generate_text_success(self): """generate_text returns text on success.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService # Mock response mock_response = MagicMock() mock_response.text = "Generated text response" mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.generate_text("Hello world") assert result == "Generated text response" @pytest.mark.asyncio async def test_generate_text_with_custom_model(self): """generate_text uses custom model when provided.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.text = "Custom model response" mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.generate_text("Hello", model="custom-model") # Verify custom model was used call_args = mock_genai.Client.return_value.models.generate_content.call_args assert call_args.kwargs.get('model') == "custom-model" @pytest.mark.asyncio async def test_generate_text_empty_response(self): """generate_text returns empty string for None response.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.text = None mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.generate_text("Hello") assert result == "" @pytest.mark.asyncio async def test_generate_text_api_error_404(self): """generate_text raises ValueError for 404 error.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_genai.Client.return_value.models.generate_content.side_effect = Exception("404 NOT_FOUND") service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="Model not found"): await service.generate_text("Hello") # ============================================================================= # 4. Animation Prompt Tests # ============================================================================= class TestAnimationPrompt: """Test generate_animation_prompt method.""" @pytest.mark.asyncio async def test_generate_animation_prompt_default(self): """generate_animation_prompt uses default prompt.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.text = "Subtle zoom with camera pan" mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.generate_animation_prompt( base64_image="base64data", mime_type="image/jpeg" ) assert result == "Subtle zoom with camera pan" @pytest.mark.asyncio async def test_generate_animation_prompt_custom(self): """generate_animation_prompt uses custom prompt when provided.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.text = "Custom animation" mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.generate_animation_prompt( base64_image="base64data", mime_type="image/jpeg", custom_prompt="Make it dramatic" ) assert result == "Custom animation" @pytest.mark.asyncio async def test_generate_animation_prompt_fallback(self): """generate_animation_prompt returns fallback on empty response.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.text = None mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.generate_animation_prompt( base64_image="base64data", mime_type="image/jpeg" ) assert result == "Cinematic subtle movement" # ============================================================================= # 5. Image Analysis Tests # ============================================================================= class TestImageAnalysis: """Test analyze_image method.""" @pytest.mark.asyncio async def test_analyze_image_success(self): """analyze_image returns analysis text.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.text = "This image shows a sunset over mountains" mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.analyze_image( base64_image="base64data", mime_type="image/jpeg", prompt="Describe this image" ) assert result == "This image shows a sunset over mountains" @pytest.mark.asyncio async def test_analyze_image_empty_response(self): """analyze_image returns empty string for None response.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.text = None mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.analyze_image( base64_image="base64data", mime_type="image/jpeg", prompt="Describe" ) assert result == "" # ============================================================================= # 6. Image Editing Tests # ============================================================================= class TestImageEditing: """Test edit_image method.""" @pytest.mark.asyncio async def test_edit_image_returns_data_uri(self): """edit_image returns base64 data URI.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService # Create mock response structure mock_inline_data = MagicMock() mock_inline_data.data = "base64imagedata" mock_inline_data.mime_type = "image/png" mock_part = MagicMock() mock_part.inline_data = mock_inline_data mock_content = MagicMock() mock_content.parts = [mock_part] mock_candidate = MagicMock() mock_candidate.content = mock_content mock_response = MagicMock() mock_response.candidates = [mock_candidate] mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.edit_image( base64_image="input-base64", mime_type="image/jpeg", prompt="Make it colorful" ) assert result == "data:image/png;base64,base64imagedata" @pytest.mark.asyncio async def test_edit_image_no_candidates(self): """edit_image raises error when no candidates returned.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_response = MagicMock() mock_response.candidates = [] mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="No candidates returned"): await service.edit_image( base64_image="input-base64", mime_type="image/jpeg", prompt="Edit" ) @pytest.mark.asyncio async def test_edit_image_no_image_data(self): """edit_image raises error when no image data in parts.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService # Part without inline_data mock_part = MagicMock() mock_part.inline_data = None mock_content = MagicMock() mock_content.parts = [mock_part] mock_candidate = MagicMock() mock_candidate.content = mock_content mock_response = MagicMock() mock_response.candidates = [mock_candidate] mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="No image data found"): await service.edit_image( base64_image="input-base64", mime_type="image/jpeg", prompt="Edit" ) @pytest.mark.asyncio async def test_edit_image_default_prompt(self): """edit_image uses default prompt when empty.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_inline_data = MagicMock() mock_inline_data.data = "base64data" mock_inline_data.mime_type = "image/png" mock_part = MagicMock() mock_part.inline_data = mock_inline_data mock_content = MagicMock() mock_content.parts = [mock_part] mock_candidate = MagicMock() mock_candidate.content = mock_content mock_response = MagicMock() mock_response.candidates = [mock_candidate] mock_genai.Client.return_value.models.generate_content.return_value = mock_response service = GeminiService(api_key="test-key") result = await service.edit_image( base64_image="input", mime_type="image/jpeg", prompt="" # Empty prompt ) assert "data:" in result # ============================================================================= # 7. Video Generation Tests # ============================================================================= class TestVideoGeneration: """Test start_video_generation method.""" @pytest.mark.asyncio async def test_start_video_returns_operation_dict(self): """start_video_generation returns operation dictionary.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_operation = MagicMock() mock_operation.name = "operations/video-123" mock_operation.done = False mock_genai.Client.return_value.models.generate_videos.return_value = mock_operation service = GeminiService(api_key="test-key") result = await service.start_video_generation( base64_image="base64data", mime_type="image/jpeg", prompt="Animate this" ) assert result["gemini_operation_name"] == "operations/video-123" assert result["done"] == False assert result["status"] == "pending" @pytest.mark.asyncio async def test_start_video_completed_immediately(self): """start_video_generation returns completed when done=True.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_operation = MagicMock() mock_operation.name = "operations/video-123" mock_operation.done = True mock_genai.Client.return_value.models.generate_videos.return_value = mock_operation service = GeminiService(api_key="test-key") result = await service.start_video_generation( base64_image="base64data", mime_type="image/jpeg", prompt="Animate this" ) assert result["status"] == "completed" @pytest.mark.asyncio async def test_start_video_with_params(self): """start_video_generation passes aspect_ratio and resolution.""" with patch('services.gemini_service.genai') as mock_genai: with patch('services.gemini_service.types'): from services.gemini_service import GeminiService mock_operation = MagicMock() mock_operation.name = "operations/video-123" mock_operation.done = False mock_genai.Client.return_value.models.generate_videos.return_value = mock_operation service = GeminiService(api_key="test-key") await service.start_video_generation( base64_image="base64data", mime_type="image/jpeg", prompt="Animate", aspect_ratio="9:16", resolution="1080p", number_of_videos=2 ) # Verify config was passed call_args = mock_genai.Client.return_value.models.generate_videos.call_args assert call_args is not None # ============================================================================= # 8. Video Status Checking Tests # ============================================================================= class TestVideoStatusChecking: """Test check_video_status method.""" @pytest.mark.asyncio async def test_check_status_pending(self): """check_video_status returns pending when not done.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_operation = MagicMock() mock_operation.done = False mock_operation.error = None mock_genai.Client.return_value.operations.get.return_value = mock_operation service = GeminiService(api_key="test-key") result = await service.check_video_status("operations/video-123") assert result["done"] == False assert result["status"] == "pending" @pytest.mark.asyncio async def test_check_status_completed_with_url(self): """check_video_status returns completed with video URL.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService # Build nested mock structure mock_video = MagicMock() mock_video.uri = "https://storage.googleapis.com/video.mp4" mock_generated_video = MagicMock() mock_generated_video.video = mock_video mock_result = MagicMock() mock_result.generated_videos = [mock_generated_video] mock_operation = MagicMock() mock_operation.done = True mock_operation.error = None mock_operation.result = mock_result mock_genai.Client.return_value.operations.get.return_value = mock_operation service = GeminiService(api_key="test-api-key") result = await service.check_video_status("operations/video-123") assert result["done"] == True assert result["status"] == "completed" assert "video_url" in result assert "test-api-key" in result["video_url"] # API key appended @pytest.mark.asyncio async def test_check_status_operation_error(self): """check_video_status returns failed on operation error.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_error = MagicMock() mock_error.message = "Content blocked by policy" mock_operation = MagicMock() mock_operation.done = True mock_operation.error = mock_error mock_genai.Client.return_value.operations.get.return_value = mock_operation service = GeminiService(api_key="test-key") result = await service.check_video_status("operations/video-123") assert result["done"] == True assert result["status"] == "failed" assert "error" in result @pytest.mark.asyncio async def test_check_status_404_expired(self): """check_video_status handles 404 for expired operation.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_genai.Client.return_value.operations.get.side_effect = Exception("404 NOT_FOUND") service = GeminiService(api_key="test-key") result = await service.check_video_status("operations/expired-123") assert result["done"] == True assert result["status"] == "failed" assert "expired" in result["error"].lower() @pytest.mark.asyncio async def test_check_status_no_video_uri(self): """check_video_status returns failed when no video URI.""" with patch('services.gemini_service.genai') as mock_genai: from services.gemini_service import GeminiService mock_result = MagicMock() mock_result.generated_videos = [] # Empty mock_operation = MagicMock() mock_operation.done = True mock_operation.error = None mock_operation.result = mock_result mock_genai.Client.return_value.operations.get.return_value = mock_operation service = GeminiService(api_key="test-key") result = await service.check_video_status("operations/video-123") assert result["status"] == "failed" assert "safety filters" in result["error"].lower() # ============================================================================= # 9. Video Download Tests # ============================================================================= class TestVideoDownload: """Test download_video method.""" @pytest.mark.asyncio async def test_download_video_saves_file(self): """download_video saves file and returns filename.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService, DOWNLOADS_DIR with patch('httpx.AsyncClient') as mock_client: mock_response = MagicMock() mock_response.content = b"fake video data" mock_response.raise_for_status = MagicMock() mock_client_instance = AsyncMock() mock_client_instance.get.return_value = mock_response mock_client_instance.__aenter__.return_value = mock_client_instance mock_client_instance.__aexit__.return_value = None mock_client.return_value = mock_client_instance service = GeminiService(api_key="test-key") # Use temp directory with tempfile.TemporaryDirectory() as temp_dir: with patch.object( __import__('services.gemini_service', fromlist=['DOWNLOADS_DIR']), 'DOWNLOADS_DIR', temp_dir ): result = await service.download_video( "https://example.com/video.mp4", "test-op-123" ) assert result == "test-op-123.mp4" @pytest.mark.asyncio async def test_download_video_http_error(self): """download_video raises error on HTTP failure.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService with patch('httpx.AsyncClient') as mock_client: mock_client_instance = AsyncMock() mock_client_instance.get.side_effect = Exception("Connection refused") mock_client_instance.__aenter__.return_value = mock_client_instance mock_client_instance.__aexit__.return_value = None mock_client.return_value = mock_client_instance service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="Failed to download"): await service.download_video( "https://example.com/video.mp4", "test-op-123" ) @pytest.mark.asyncio async def test_download_video_follows_redirects(self): """download_video client is configured to follow redirects.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService with patch('httpx.AsyncClient') as mock_client: mock_response = MagicMock() mock_response.content = b"video data" mock_response.raise_for_status = MagicMock() mock_client_instance = AsyncMock() mock_client_instance.get.return_value = mock_response mock_client_instance.__aenter__.return_value = mock_client_instance mock_client_instance.__aexit__.return_value = None mock_client.return_value = mock_client_instance service = GeminiService(api_key="test-key") with tempfile.TemporaryDirectory() as temp_dir: with patch('services.gemini_service.DOWNLOADS_DIR', temp_dir): await service.download_video( "https://example.com/video.mp4", "redirect-test" ) # Verify follow_redirects=True was passed mock_client.assert_called_with(timeout=120.0, follow_redirects=True) # ============================================================================= # 10. Error Handling Tests # ============================================================================= class TestErrorHandling: """Test _handle_api_error method.""" def test_handle_api_error_404(self): """_handle_api_error raises ValueError for 404.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="Model not found"): service._handle_api_error(Exception("Error 404"), "test-model") def test_handle_api_error_not_found(self): """_handle_api_error handles NOT_FOUND in message.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="Model not found"): service._handle_api_error(Exception("NOT_FOUND: resource"), "test-model") def test_handle_api_error_entity_not_found(self): """_handle_api_error handles 'Requested entity was not found'.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="Model not found"): service._handle_api_error( Exception("Requested entity was not found"), "test-model" ) def test_handle_api_error_bracket_5_pattern(self): """_handle_api_error handles [5, pattern.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService service = GeminiService(api_key="test-key") with pytest.raises(ValueError, match="Model not found"): service._handle_api_error( Exception("Response [5, 'NOT_FOUND']"), "test-model" ) def test_handle_api_error_reraises_other(self): """_handle_api_error re-raises non-404 errors.""" with patch('services.gemini_service.genai'): from services.gemini_service import GeminiService service = GeminiService(api_key="test-key") with pytest.raises(RuntimeError, match="Connection timeout"): service._handle_api_error( RuntimeError("Connection timeout"), "test-model" ) # ============================================================================= # 11. Downloads Directory Tests # ============================================================================= class TestDownloadsDirectory: """Test downloads directory handling.""" def test_downloads_dir_exists(self): """DOWNLOADS_DIR is created on module import.""" from services.gemini_service import DOWNLOADS_DIR assert os.path.exists(DOWNLOADS_DIR) assert os.path.isdir(DOWNLOADS_DIR) def test_downloads_dir_is_in_project(self): """DOWNLOADS_DIR is within project directory.""" from services.gemini_service import DOWNLOADS_DIR assert "downloads" in DOWNLOADS_DIR if __name__ == "__main__": pytest.main([__file__, "-v"])