jebin2 commited on
Commit
a83c10f
·
1 Parent(s): fef313c
Files changed (1) hide show
  1. tests/test_gemini_service.py +814 -0
tests/test_gemini_service.py ADDED
@@ -0,0 +1,814 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rigorous Tests for Gemini AI Service.
3
+
4
+ Tests cover:
5
+ 1. Initialization & API key handling
6
+ 2. Concurrency semaphores
7
+ 3. Text generation
8
+ 4. Animation prompt generation
9
+ 5. Image analysis & editing
10
+ 6. Video generation, status checking, downloading
11
+ 7. Error handling
12
+ """
13
+ import pytest
14
+ import asyncio
15
+ import os
16
+ import tempfile
17
+ from unittest.mock import patch, MagicMock, AsyncMock, PropertyMock
18
+ from datetime import datetime
19
+
20
+
21
+ # =============================================================================
22
+ # 1. Initialization & Configuration Tests
23
+ # =============================================================================
24
+
25
+ class TestGeminiServiceInit:
26
+ """Test GeminiService initialization and configuration."""
27
+
28
+ def test_init_with_explicit_api_key(self):
29
+ """Service initializes with explicit API key."""
30
+ with patch('services.gemini_service.genai') as mock_genai:
31
+ from services.gemini_service import GeminiService
32
+
33
+ service = GeminiService(api_key="test-key-123")
34
+
35
+ assert service.api_key == "test-key-123"
36
+ mock_genai.Client.assert_called_once_with(api_key="test-key-123")
37
+
38
+ def test_init_with_env_fallback(self):
39
+ """Service falls back to environment variable for API key."""
40
+ with patch('services.gemini_service.genai') as mock_genai:
41
+ with patch.dict(os.environ, {"GEMINI_API_KEY": "env-key-456"}):
42
+ from services.gemini_service import GeminiService
43
+
44
+ service = GeminiService()
45
+
46
+ assert service.api_key == "env-key-456"
47
+
48
+ def test_init_fails_without_api_key(self):
49
+ """Service raises error when no API key available."""
50
+ with patch.dict(os.environ, {}, clear=True):
51
+ # Remove GEMINI_API_KEY if present
52
+ os.environ.pop("GEMINI_API_KEY", None)
53
+ os.environ.pop("GEMINI_API_KEYS", None)
54
+
55
+ from services.gemini_service import get_gemini_api_key
56
+
57
+ with pytest.raises(ValueError, match="Server Authentication Error"):
58
+ get_gemini_api_key()
59
+
60
+ def test_models_dict_has_required_entries(self):
61
+ """MODELS dictionary has all required model names."""
62
+ from services.gemini_service import MODELS
63
+
64
+ assert "text_generation" in MODELS
65
+ assert "image_edit" in MODELS
66
+ assert "video_generation" in MODELS
67
+ assert all(isinstance(v, str) for v in MODELS.values())
68
+
69
+
70
+ # =============================================================================
71
+ # 2. Semaphore Concurrency Tests
72
+ # =============================================================================
73
+
74
+ class TestSemaphoreConcurrency:
75
+ """Test concurrency control via semaphores."""
76
+
77
+ def test_video_semaphore_respects_limit(self):
78
+ """Video semaphore uses MAX_CONCURRENT_VIDEOS."""
79
+ # Reset global
80
+ import services.gemini_service as gs
81
+ gs._video_semaphore = None
82
+
83
+ with patch.object(gs, 'MAX_CONCURRENT_VIDEOS', 3):
84
+ gs._video_semaphore = None # Reset
85
+ sem = gs.get_video_semaphore()
86
+ # Semaphore internal value
87
+ assert sem._value == 3
88
+
89
+ def test_image_semaphore_respects_limit(self):
90
+ """Image semaphore uses MAX_CONCURRENT_IMAGES."""
91
+ import services.gemini_service as gs
92
+ gs._image_semaphore = None
93
+
94
+ with patch.object(gs, 'MAX_CONCURRENT_IMAGES', 5):
95
+ gs._image_semaphore = None
96
+ sem = gs.get_image_semaphore()
97
+ assert sem._value == 5
98
+
99
+ def test_text_semaphore_respects_limit(self):
100
+ """Text semaphore uses MAX_CONCURRENT_TEXT."""
101
+ import services.gemini_service as gs
102
+ gs._text_semaphore = None
103
+
104
+ with patch.object(gs, 'MAX_CONCURRENT_TEXT', 10):
105
+ gs._text_semaphore = None
106
+ sem = gs.get_text_semaphore()
107
+ assert sem._value == 10
108
+
109
+ def test_semaphores_are_singletons(self):
110
+ """Calling get_*_semaphore multiple times returns same object."""
111
+ import services.gemini_service as gs
112
+ gs._video_semaphore = None
113
+ gs._image_semaphore = None
114
+ gs._text_semaphore = None
115
+
116
+ video1 = gs.get_video_semaphore()
117
+ video2 = gs.get_video_semaphore()
118
+ assert video1 is video2
119
+
120
+ image1 = gs.get_image_semaphore()
121
+ image2 = gs.get_image_semaphore()
122
+ assert image1 is image2
123
+
124
+ text1 = gs.get_text_semaphore()
125
+ text2 = gs.get_text_semaphore()
126
+ assert text1 is text2
127
+
128
+
129
+ # =============================================================================
130
+ # 3. Text Generation Tests
131
+ # =============================================================================
132
+
133
+ class TestTextGeneration:
134
+ """Test generate_text method."""
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_generate_text_success(self):
138
+ """generate_text returns text on success."""
139
+ with patch('services.gemini_service.genai') as mock_genai:
140
+ from services.gemini_service import GeminiService
141
+
142
+ # Mock response
143
+ mock_response = MagicMock()
144
+ mock_response.text = "Generated text response"
145
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
146
+
147
+ service = GeminiService(api_key="test-key")
148
+ result = await service.generate_text("Hello world")
149
+
150
+ assert result == "Generated text response"
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_generate_text_with_custom_model(self):
154
+ """generate_text uses custom model when provided."""
155
+ with patch('services.gemini_service.genai') as mock_genai:
156
+ from services.gemini_service import GeminiService
157
+
158
+ mock_response = MagicMock()
159
+ mock_response.text = "Custom model response"
160
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
161
+
162
+ service = GeminiService(api_key="test-key")
163
+ result = await service.generate_text("Hello", model="custom-model")
164
+
165
+ # Verify custom model was used
166
+ call_args = mock_genai.Client.return_value.models.generate_content.call_args
167
+ assert call_args.kwargs.get('model') == "custom-model"
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_generate_text_empty_response(self):
171
+ """generate_text returns empty string for None response."""
172
+ with patch('services.gemini_service.genai') as mock_genai:
173
+ from services.gemini_service import GeminiService
174
+
175
+ mock_response = MagicMock()
176
+ mock_response.text = None
177
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
178
+
179
+ service = GeminiService(api_key="test-key")
180
+ result = await service.generate_text("Hello")
181
+
182
+ assert result == ""
183
+
184
+ @pytest.mark.asyncio
185
+ async def test_generate_text_api_error_404(self):
186
+ """generate_text raises ValueError for 404 error."""
187
+ with patch('services.gemini_service.genai') as mock_genai:
188
+ from services.gemini_service import GeminiService
189
+
190
+ mock_genai.Client.return_value.models.generate_content.side_effect = Exception("404 NOT_FOUND")
191
+
192
+ service = GeminiService(api_key="test-key")
193
+
194
+ with pytest.raises(ValueError, match="Model not found"):
195
+ await service.generate_text("Hello")
196
+
197
+
198
+ # =============================================================================
199
+ # 4. Animation Prompt Tests
200
+ # =============================================================================
201
+
202
+ class TestAnimationPrompt:
203
+ """Test generate_animation_prompt method."""
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_generate_animation_prompt_default(self):
207
+ """generate_animation_prompt uses default prompt."""
208
+ with patch('services.gemini_service.genai') as mock_genai:
209
+ with patch('services.gemini_service.types'):
210
+ from services.gemini_service import GeminiService
211
+
212
+ mock_response = MagicMock()
213
+ mock_response.text = "Subtle zoom with camera pan"
214
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
215
+
216
+ service = GeminiService(api_key="test-key")
217
+ result = await service.generate_animation_prompt(
218
+ base64_image="base64data",
219
+ mime_type="image/jpeg"
220
+ )
221
+
222
+ assert result == "Subtle zoom with camera pan"
223
+
224
+ @pytest.mark.asyncio
225
+ async def test_generate_animation_prompt_custom(self):
226
+ """generate_animation_prompt uses custom prompt when provided."""
227
+ with patch('services.gemini_service.genai') as mock_genai:
228
+ with patch('services.gemini_service.types'):
229
+ from services.gemini_service import GeminiService
230
+
231
+ mock_response = MagicMock()
232
+ mock_response.text = "Custom animation"
233
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
234
+
235
+ service = GeminiService(api_key="test-key")
236
+ result = await service.generate_animation_prompt(
237
+ base64_image="base64data",
238
+ mime_type="image/jpeg",
239
+ custom_prompt="Make it dramatic"
240
+ )
241
+
242
+ assert result == "Custom animation"
243
+
244
+ @pytest.mark.asyncio
245
+ async def test_generate_animation_prompt_fallback(self):
246
+ """generate_animation_prompt returns fallback on empty response."""
247
+ with patch('services.gemini_service.genai') as mock_genai:
248
+ with patch('services.gemini_service.types'):
249
+ from services.gemini_service import GeminiService
250
+
251
+ mock_response = MagicMock()
252
+ mock_response.text = None
253
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
254
+
255
+ service = GeminiService(api_key="test-key")
256
+ result = await service.generate_animation_prompt(
257
+ base64_image="base64data",
258
+ mime_type="image/jpeg"
259
+ )
260
+
261
+ assert result == "Cinematic subtle movement"
262
+
263
+
264
+ # =============================================================================
265
+ # 5. Image Analysis Tests
266
+ # =============================================================================
267
+
268
+ class TestImageAnalysis:
269
+ """Test analyze_image method."""
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_analyze_image_success(self):
273
+ """analyze_image returns analysis text."""
274
+ with patch('services.gemini_service.genai') as mock_genai:
275
+ with patch('services.gemini_service.types'):
276
+ from services.gemini_service import GeminiService
277
+
278
+ mock_response = MagicMock()
279
+ mock_response.text = "This image shows a sunset over mountains"
280
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
281
+
282
+ service = GeminiService(api_key="test-key")
283
+ result = await service.analyze_image(
284
+ base64_image="base64data",
285
+ mime_type="image/jpeg",
286
+ prompt="Describe this image"
287
+ )
288
+
289
+ assert result == "This image shows a sunset over mountains"
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_analyze_image_empty_response(self):
293
+ """analyze_image returns empty string for None response."""
294
+ with patch('services.gemini_service.genai') as mock_genai:
295
+ with patch('services.gemini_service.types'):
296
+ from services.gemini_service import GeminiService
297
+
298
+ mock_response = MagicMock()
299
+ mock_response.text = None
300
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
301
+
302
+ service = GeminiService(api_key="test-key")
303
+ result = await service.analyze_image(
304
+ base64_image="base64data",
305
+ mime_type="image/jpeg",
306
+ prompt="Describe"
307
+ )
308
+
309
+ assert result == ""
310
+
311
+
312
+ # =============================================================================
313
+ # 6. Image Editing Tests
314
+ # =============================================================================
315
+
316
+ class TestImageEditing:
317
+ """Test edit_image method."""
318
+
319
+ @pytest.mark.asyncio
320
+ async def test_edit_image_returns_data_uri(self):
321
+ """edit_image returns base64 data URI."""
322
+ with patch('services.gemini_service.genai') as mock_genai:
323
+ from services.gemini_service import GeminiService
324
+
325
+ # Create mock response structure
326
+ mock_inline_data = MagicMock()
327
+ mock_inline_data.data = "base64imagedata"
328
+ mock_inline_data.mime_type = "image/png"
329
+
330
+ mock_part = MagicMock()
331
+ mock_part.inline_data = mock_inline_data
332
+
333
+ mock_content = MagicMock()
334
+ mock_content.parts = [mock_part]
335
+
336
+ mock_candidate = MagicMock()
337
+ mock_candidate.content = mock_content
338
+
339
+ mock_response = MagicMock()
340
+ mock_response.candidates = [mock_candidate]
341
+
342
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
343
+
344
+ service = GeminiService(api_key="test-key")
345
+ result = await service.edit_image(
346
+ base64_image="input-base64",
347
+ mime_type="image/jpeg",
348
+ prompt="Make it colorful"
349
+ )
350
+
351
+ assert result == "data:image/png;base64,base64imagedata"
352
+
353
+ @pytest.mark.asyncio
354
+ async def test_edit_image_no_candidates(self):
355
+ """edit_image raises error when no candidates returned."""
356
+ with patch('services.gemini_service.genai') as mock_genai:
357
+ from services.gemini_service import GeminiService
358
+
359
+ mock_response = MagicMock()
360
+ mock_response.candidates = []
361
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
362
+
363
+ service = GeminiService(api_key="test-key")
364
+
365
+ with pytest.raises(ValueError, match="No candidates returned"):
366
+ await service.edit_image(
367
+ base64_image="input-base64",
368
+ mime_type="image/jpeg",
369
+ prompt="Edit"
370
+ )
371
+
372
+ @pytest.mark.asyncio
373
+ async def test_edit_image_no_image_data(self):
374
+ """edit_image raises error when no image data in parts."""
375
+ with patch('services.gemini_service.genai') as mock_genai:
376
+ from services.gemini_service import GeminiService
377
+
378
+ # Part without inline_data
379
+ mock_part = MagicMock()
380
+ mock_part.inline_data = None
381
+
382
+ mock_content = MagicMock()
383
+ mock_content.parts = [mock_part]
384
+
385
+ mock_candidate = MagicMock()
386
+ mock_candidate.content = mock_content
387
+
388
+ mock_response = MagicMock()
389
+ mock_response.candidates = [mock_candidate]
390
+
391
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
392
+
393
+ service = GeminiService(api_key="test-key")
394
+
395
+ with pytest.raises(ValueError, match="No image data found"):
396
+ await service.edit_image(
397
+ base64_image="input-base64",
398
+ mime_type="image/jpeg",
399
+ prompt="Edit"
400
+ )
401
+
402
+ @pytest.mark.asyncio
403
+ async def test_edit_image_default_prompt(self):
404
+ """edit_image uses default prompt when empty."""
405
+ with patch('services.gemini_service.genai') as mock_genai:
406
+ with patch('services.gemini_service.types'):
407
+ from services.gemini_service import GeminiService
408
+
409
+ mock_inline_data = MagicMock()
410
+ mock_inline_data.data = "base64data"
411
+ mock_inline_data.mime_type = "image/png"
412
+
413
+ mock_part = MagicMock()
414
+ mock_part.inline_data = mock_inline_data
415
+
416
+ mock_content = MagicMock()
417
+ mock_content.parts = [mock_part]
418
+
419
+ mock_candidate = MagicMock()
420
+ mock_candidate.content = mock_content
421
+
422
+ mock_response = MagicMock()
423
+ mock_response.candidates = [mock_candidate]
424
+
425
+ mock_genai.Client.return_value.models.generate_content.return_value = mock_response
426
+
427
+ service = GeminiService(api_key="test-key")
428
+ result = await service.edit_image(
429
+ base64_image="input",
430
+ mime_type="image/jpeg",
431
+ prompt="" # Empty prompt
432
+ )
433
+
434
+ assert "data:" in result
435
+
436
+
437
+ # =============================================================================
438
+ # 7. Video Generation Tests
439
+ # =============================================================================
440
+
441
+ class TestVideoGeneration:
442
+ """Test start_video_generation method."""
443
+
444
+ @pytest.mark.asyncio
445
+ async def test_start_video_returns_operation_dict(self):
446
+ """start_video_generation returns operation dictionary."""
447
+ with patch('services.gemini_service.genai') as mock_genai:
448
+ with patch('services.gemini_service.types'):
449
+ from services.gemini_service import GeminiService
450
+
451
+ mock_operation = MagicMock()
452
+ mock_operation.name = "operations/video-123"
453
+ mock_operation.done = False
454
+
455
+ mock_genai.Client.return_value.models.generate_videos.return_value = mock_operation
456
+
457
+ service = GeminiService(api_key="test-key")
458
+ result = await service.start_video_generation(
459
+ base64_image="base64data",
460
+ mime_type="image/jpeg",
461
+ prompt="Animate this"
462
+ )
463
+
464
+ assert result["gemini_operation_name"] == "operations/video-123"
465
+ assert result["done"] == False
466
+ assert result["status"] == "pending"
467
+
468
+ @pytest.mark.asyncio
469
+ async def test_start_video_completed_immediately(self):
470
+ """start_video_generation returns completed when done=True."""
471
+ with patch('services.gemini_service.genai') as mock_genai:
472
+ with patch('services.gemini_service.types'):
473
+ from services.gemini_service import GeminiService
474
+
475
+ mock_operation = MagicMock()
476
+ mock_operation.name = "operations/video-123"
477
+ mock_operation.done = True
478
+
479
+ mock_genai.Client.return_value.models.generate_videos.return_value = mock_operation
480
+
481
+ service = GeminiService(api_key="test-key")
482
+ result = await service.start_video_generation(
483
+ base64_image="base64data",
484
+ mime_type="image/jpeg",
485
+ prompt="Animate this"
486
+ )
487
+
488
+ assert result["status"] == "completed"
489
+
490
+ @pytest.mark.asyncio
491
+ async def test_start_video_with_params(self):
492
+ """start_video_generation passes aspect_ratio and resolution."""
493
+ with patch('services.gemini_service.genai') as mock_genai:
494
+ with patch('services.gemini_service.types'):
495
+ from services.gemini_service import GeminiService
496
+
497
+ mock_operation = MagicMock()
498
+ mock_operation.name = "operations/video-123"
499
+ mock_operation.done = False
500
+
501
+ mock_genai.Client.return_value.models.generate_videos.return_value = mock_operation
502
+
503
+ service = GeminiService(api_key="test-key")
504
+ await service.start_video_generation(
505
+ base64_image="base64data",
506
+ mime_type="image/jpeg",
507
+ prompt="Animate",
508
+ aspect_ratio="9:16",
509
+ resolution="1080p",
510
+ number_of_videos=2
511
+ )
512
+
513
+ # Verify config was passed
514
+ call_args = mock_genai.Client.return_value.models.generate_videos.call_args
515
+ assert call_args is not None
516
+
517
+
518
+ # =============================================================================
519
+ # 8. Video Status Checking Tests
520
+ # =============================================================================
521
+
522
+ class TestVideoStatusChecking:
523
+ """Test check_video_status method."""
524
+
525
+ @pytest.mark.asyncio
526
+ async def test_check_status_pending(self):
527
+ """check_video_status returns pending when not done."""
528
+ with patch('services.gemini_service.genai') as mock_genai:
529
+ from services.gemini_service import GeminiService
530
+
531
+ mock_operation = MagicMock()
532
+ mock_operation.done = False
533
+ mock_operation.error = None
534
+
535
+ mock_genai.Client.return_value.operations.get.return_value = mock_operation
536
+
537
+ service = GeminiService(api_key="test-key")
538
+ result = await service.check_video_status("operations/video-123")
539
+
540
+ assert result["done"] == False
541
+ assert result["status"] == "pending"
542
+
543
+ @pytest.mark.asyncio
544
+ async def test_check_status_completed_with_url(self):
545
+ """check_video_status returns completed with video URL."""
546
+ with patch('services.gemini_service.genai') as mock_genai:
547
+ from services.gemini_service import GeminiService
548
+
549
+ # Build nested mock structure
550
+ mock_video = MagicMock()
551
+ mock_video.uri = "https://storage.googleapis.com/video.mp4"
552
+
553
+ mock_generated_video = MagicMock()
554
+ mock_generated_video.video = mock_video
555
+
556
+ mock_result = MagicMock()
557
+ mock_result.generated_videos = [mock_generated_video]
558
+
559
+ mock_operation = MagicMock()
560
+ mock_operation.done = True
561
+ mock_operation.error = None
562
+ mock_operation.result = mock_result
563
+
564
+ mock_genai.Client.return_value.operations.get.return_value = mock_operation
565
+
566
+ service = GeminiService(api_key="test-api-key")
567
+ result = await service.check_video_status("operations/video-123")
568
+
569
+ assert result["done"] == True
570
+ assert result["status"] == "completed"
571
+ assert "video_url" in result
572
+ assert "test-api-key" in result["video_url"] # API key appended
573
+
574
+ @pytest.mark.asyncio
575
+ async def test_check_status_operation_error(self):
576
+ """check_video_status returns failed on operation error."""
577
+ with patch('services.gemini_service.genai') as mock_genai:
578
+ from services.gemini_service import GeminiService
579
+
580
+ mock_error = MagicMock()
581
+ mock_error.message = "Content blocked by policy"
582
+
583
+ mock_operation = MagicMock()
584
+ mock_operation.done = True
585
+ mock_operation.error = mock_error
586
+
587
+ mock_genai.Client.return_value.operations.get.return_value = mock_operation
588
+
589
+ service = GeminiService(api_key="test-key")
590
+ result = await service.check_video_status("operations/video-123")
591
+
592
+ assert result["done"] == True
593
+ assert result["status"] == "failed"
594
+ assert "error" in result
595
+
596
+ @pytest.mark.asyncio
597
+ async def test_check_status_404_expired(self):
598
+ """check_video_status handles 404 for expired operation."""
599
+ with patch('services.gemini_service.genai') as mock_genai:
600
+ from services.gemini_service import GeminiService
601
+
602
+ mock_genai.Client.return_value.operations.get.side_effect = Exception("404 NOT_FOUND")
603
+
604
+ service = GeminiService(api_key="test-key")
605
+ result = await service.check_video_status("operations/expired-123")
606
+
607
+ assert result["done"] == True
608
+ assert result["status"] == "failed"
609
+ assert "expired" in result["error"].lower()
610
+
611
+ @pytest.mark.asyncio
612
+ async def test_check_status_no_video_uri(self):
613
+ """check_video_status returns failed when no video URI."""
614
+ with patch('services.gemini_service.genai') as mock_genai:
615
+ from services.gemini_service import GeminiService
616
+
617
+ mock_result = MagicMock()
618
+ mock_result.generated_videos = [] # Empty
619
+
620
+ mock_operation = MagicMock()
621
+ mock_operation.done = True
622
+ mock_operation.error = None
623
+ mock_operation.result = mock_result
624
+
625
+ mock_genai.Client.return_value.operations.get.return_value = mock_operation
626
+
627
+ service = GeminiService(api_key="test-key")
628
+ result = await service.check_video_status("operations/video-123")
629
+
630
+ assert result["status"] == "failed"
631
+ assert "safety filters" in result["error"].lower()
632
+
633
+
634
+ # =============================================================================
635
+ # 9. Video Download Tests
636
+ # =============================================================================
637
+
638
+ class TestVideoDownload:
639
+ """Test download_video method."""
640
+
641
+ @pytest.mark.asyncio
642
+ async def test_download_video_saves_file(self):
643
+ """download_video saves file and returns filename."""
644
+ with patch('services.gemini_service.genai'):
645
+ from services.gemini_service import GeminiService, DOWNLOADS_DIR
646
+
647
+ with patch('httpx.AsyncClient') as mock_client:
648
+ mock_response = MagicMock()
649
+ mock_response.content = b"fake video data"
650
+ mock_response.raise_for_status = MagicMock()
651
+
652
+ mock_client_instance = AsyncMock()
653
+ mock_client_instance.get.return_value = mock_response
654
+ mock_client_instance.__aenter__.return_value = mock_client_instance
655
+ mock_client_instance.__aexit__.return_value = None
656
+ mock_client.return_value = mock_client_instance
657
+
658
+ service = GeminiService(api_key="test-key")
659
+
660
+ # Use temp directory
661
+ with tempfile.TemporaryDirectory() as temp_dir:
662
+ with patch.object(
663
+ __import__('services.gemini_service', fromlist=['DOWNLOADS_DIR']),
664
+ 'DOWNLOADS_DIR',
665
+ temp_dir
666
+ ):
667
+ result = await service.download_video(
668
+ "https://example.com/video.mp4",
669
+ "test-op-123"
670
+ )
671
+
672
+ assert result == "test-op-123.mp4"
673
+
674
+ @pytest.mark.asyncio
675
+ async def test_download_video_http_error(self):
676
+ """download_video raises error on HTTP failure."""
677
+ with patch('services.gemini_service.genai'):
678
+ from services.gemini_service import GeminiService
679
+
680
+ with patch('httpx.AsyncClient') as mock_client:
681
+ mock_client_instance = AsyncMock()
682
+ mock_client_instance.get.side_effect = Exception("Connection refused")
683
+ mock_client_instance.__aenter__.return_value = mock_client_instance
684
+ mock_client_instance.__aexit__.return_value = None
685
+ mock_client.return_value = mock_client_instance
686
+
687
+ service = GeminiService(api_key="test-key")
688
+
689
+ with pytest.raises(ValueError, match="Failed to download"):
690
+ await service.download_video(
691
+ "https://example.com/video.mp4",
692
+ "test-op-123"
693
+ )
694
+
695
+ @pytest.mark.asyncio
696
+ async def test_download_video_follows_redirects(self):
697
+ """download_video client is configured to follow redirects."""
698
+ with patch('services.gemini_service.genai'):
699
+ from services.gemini_service import GeminiService
700
+
701
+ with patch('httpx.AsyncClient') as mock_client:
702
+ mock_response = MagicMock()
703
+ mock_response.content = b"video data"
704
+ mock_response.raise_for_status = MagicMock()
705
+
706
+ mock_client_instance = AsyncMock()
707
+ mock_client_instance.get.return_value = mock_response
708
+ mock_client_instance.__aenter__.return_value = mock_client_instance
709
+ mock_client_instance.__aexit__.return_value = None
710
+ mock_client.return_value = mock_client_instance
711
+
712
+ service = GeminiService(api_key="test-key")
713
+
714
+ with tempfile.TemporaryDirectory() as temp_dir:
715
+ with patch('services.gemini_service.DOWNLOADS_DIR', temp_dir):
716
+ await service.download_video(
717
+ "https://example.com/video.mp4",
718
+ "redirect-test"
719
+ )
720
+
721
+ # Verify follow_redirects=True was passed
722
+ mock_client.assert_called_with(timeout=120.0, follow_redirects=True)
723
+
724
+
725
+ # =============================================================================
726
+ # 10. Error Handling Tests
727
+ # =============================================================================
728
+
729
+ class TestErrorHandling:
730
+ """Test _handle_api_error method."""
731
+
732
+ def test_handle_api_error_404(self):
733
+ """_handle_api_error raises ValueError for 404."""
734
+ with patch('services.gemini_service.genai'):
735
+ from services.gemini_service import GeminiService
736
+
737
+ service = GeminiService(api_key="test-key")
738
+
739
+ with pytest.raises(ValueError, match="Model not found"):
740
+ service._handle_api_error(Exception("Error 404"), "test-model")
741
+
742
+ def test_handle_api_error_not_found(self):
743
+ """_handle_api_error handles NOT_FOUND in message."""
744
+ with patch('services.gemini_service.genai'):
745
+ from services.gemini_service import GeminiService
746
+
747
+ service = GeminiService(api_key="test-key")
748
+
749
+ with pytest.raises(ValueError, match="Model not found"):
750
+ service._handle_api_error(Exception("NOT_FOUND: resource"), "test-model")
751
+
752
+ def test_handle_api_error_entity_not_found(self):
753
+ """_handle_api_error handles 'Requested entity was not found'."""
754
+ with patch('services.gemini_service.genai'):
755
+ from services.gemini_service import GeminiService
756
+
757
+ service = GeminiService(api_key="test-key")
758
+
759
+ with pytest.raises(ValueError, match="Model not found"):
760
+ service._handle_api_error(
761
+ Exception("Requested entity was not found"),
762
+ "test-model"
763
+ )
764
+
765
+ def test_handle_api_error_bracket_5_pattern(self):
766
+ """_handle_api_error handles [5, pattern."""
767
+ with patch('services.gemini_service.genai'):
768
+ from services.gemini_service import GeminiService
769
+
770
+ service = GeminiService(api_key="test-key")
771
+
772
+ with pytest.raises(ValueError, match="Model not found"):
773
+ service._handle_api_error(
774
+ Exception("Response [5, 'NOT_FOUND']"),
775
+ "test-model"
776
+ )
777
+
778
+ def test_handle_api_error_reraises_other(self):
779
+ """_handle_api_error re-raises non-404 errors."""
780
+ with patch('services.gemini_service.genai'):
781
+ from services.gemini_service import GeminiService
782
+
783
+ service = GeminiService(api_key="test-key")
784
+
785
+ with pytest.raises(RuntimeError, match="Connection timeout"):
786
+ service._handle_api_error(
787
+ RuntimeError("Connection timeout"),
788
+ "test-model"
789
+ )
790
+
791
+
792
+ # =============================================================================
793
+ # 11. Downloads Directory Tests
794
+ # =============================================================================
795
+
796
+ class TestDownloadsDirectory:
797
+ """Test downloads directory handling."""
798
+
799
+ def test_downloads_dir_exists(self):
800
+ """DOWNLOADS_DIR is created on module import."""
801
+ from services.gemini_service import DOWNLOADS_DIR
802
+
803
+ assert os.path.exists(DOWNLOADS_DIR)
804
+ assert os.path.isdir(DOWNLOADS_DIR)
805
+
806
+ def test_downloads_dir_is_in_project(self):
807
+ """DOWNLOADS_DIR is within project directory."""
808
+ from services.gemini_service import DOWNLOADS_DIR
809
+
810
+ assert "downloads" in DOWNLOADS_DIR
811
+
812
+
813
+ if __name__ == "__main__":
814
+ pytest.main([__file__, "-v"])