apigateway / tests /test_gemini_service.py
jebin2's picture
gemini
a83c10f
"""
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"])