Spaces:
Sleeping
Sleeping
worker test
Browse files- tests/test_worker_pool.py +920 -25
tests/test_worker_pool.py
CHANGED
|
@@ -1,68 +1,171 @@
|
|
| 1 |
"""
|
| 2 |
-
Tests for Priority-Tier Worker Pool implementation.
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import pytest
|
| 6 |
import asyncio
|
| 7 |
-
from unittest.mock import patch, MagicMock, AsyncMock
|
| 8 |
from datetime import datetime, timedelta
|
|
|
|
| 9 |
|
| 10 |
# Test the modular priority worker pool
|
| 11 |
from services.priority_worker_pool import (
|
| 12 |
PriorityWorkerPool,
|
| 13 |
PriorityWorker,
|
| 14 |
WorkerConfig,
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
# Test the Gemini-specific implementation
|
| 19 |
from services.gemini_job_worker import (
|
| 20 |
-
get_priority_for_job_type,
|
| 21 |
JOB_PRIORITY_MAP,
|
| 22 |
GeminiJobProcessor
|
| 23 |
)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
class TestPriorityMapping:
|
| 27 |
"""Test job type to priority mapping."""
|
| 28 |
|
| 29 |
def test_text_job_is_fast(self):
|
| 30 |
-
assert
|
| 31 |
|
| 32 |
def test_analyze_job_is_fast(self):
|
| 33 |
-
assert
|
| 34 |
|
| 35 |
def test_animation_prompt_is_fast(self):
|
| 36 |
-
assert
|
| 37 |
|
| 38 |
def test_image_job_is_medium(self):
|
| 39 |
-
assert
|
| 40 |
|
| 41 |
def test_edit_image_is_medium(self):
|
| 42 |
-
assert
|
| 43 |
|
| 44 |
def test_video_job_is_slow(self):
|
| 45 |
-
assert
|
| 46 |
|
| 47 |
def test_unknown_job_defaults_to_fast(self):
|
| 48 |
-
assert
|
|
|
|
| 49 |
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
class TestIntervalMapping:
|
| 52 |
"""Test priority to interval mapping."""
|
| 53 |
|
| 54 |
-
def
|
| 55 |
-
|
|
|
|
| 56 |
|
| 57 |
-
def
|
| 58 |
-
|
|
|
|
| 59 |
|
| 60 |
-
def
|
| 61 |
-
|
|
|
|
| 62 |
|
| 63 |
def test_unknown_defaults_to_slow(self):
|
| 64 |
assert get_interval_for_priority("unknown") == 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
class TestJobPriorityMap:
|
| 68 |
"""Test that all expected job types are covered."""
|
|
@@ -71,7 +174,16 @@ class TestJobPriorityMap:
|
|
| 71 |
expected_types = ["text", "analyze", "animation_prompt", "image", "edit_image", "video"]
|
| 72 |
for job_type in expected_types:
|
| 73 |
assert job_type in JOB_PRIORITY_MAP, f"Job type '{job_type}' not in priority map"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
class TestWorkerPoolConfiguration:
|
| 77 |
"""Test worker pool configuration."""
|
|
@@ -82,9 +194,10 @@ class TestWorkerPoolConfiguration:
|
|
| 82 |
assert config.fast_workers == 5
|
| 83 |
assert config.medium_workers == 5
|
| 84 |
assert config.slow_workers == 5
|
| 85 |
-
assert config.fast_interval ==
|
| 86 |
-
assert config.medium_interval ==
|
| 87 |
-
assert config.slow_interval ==
|
|
|
|
| 88 |
|
| 89 |
def test_custom_config(self):
|
| 90 |
"""Test WorkerConfig with custom values."""
|
|
@@ -94,25 +207,42 @@ class TestWorkerPoolConfiguration:
|
|
| 94 |
slow_workers=1,
|
| 95 |
fast_interval=10,
|
| 96 |
medium_interval=60,
|
| 97 |
-
slow_interval=120
|
|
|
|
| 98 |
)
|
| 99 |
assert config.fast_workers == 3
|
| 100 |
assert config.medium_workers == 2
|
| 101 |
assert config.slow_workers == 1
|
|
|
|
| 102 |
|
| 103 |
def test_total_workers_calculation(self):
|
| 104 |
"""Test total workers from config."""
|
| 105 |
config = WorkerConfig(fast_workers=5, medium_workers=5, slow_workers=5)
|
| 106 |
total = config.fast_workers + config.medium_workers + config.slow_workers
|
| 107 |
assert total == 15
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
class TestPriorityWorker:
|
| 111 |
"""Test individual worker behavior."""
|
| 112 |
|
| 113 |
def test_worker_has_correct_attributes(self):
|
| 114 |
"""Test worker initialization."""
|
| 115 |
-
# PriorityWorker now requires more args, test with mocks
|
| 116 |
worker = PriorityWorker(
|
| 117 |
worker_id=0,
|
| 118 |
priority="fast",
|
|
@@ -127,9 +257,581 @@ class TestPriorityWorker:
|
|
| 127 |
assert worker.poll_interval == 5
|
| 128 |
assert worker._running == False
|
| 129 |
assert worker._current_job_id is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
class TestCancelEndpoint:
|
| 134 |
"""Test cancel job endpoint logic."""
|
| 135 |
|
|
@@ -138,7 +840,6 @@ class TestCancelEndpoint:
|
|
| 138 |
valid_statuses = ["queued"]
|
| 139 |
invalid_statuses = ["processing", "completed", "failed", "cancelled"]
|
| 140 |
|
| 141 |
-
# This is a logic validation, actual HTTP testing would need the app
|
| 142 |
for status in valid_statuses:
|
| 143 |
assert status == "queued"
|
| 144 |
|
|
@@ -146,5 +847,199 @@ class TestCancelEndpoint:
|
|
| 146 |
assert status != "queued"
|
| 147 |
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
if __name__ == "__main__":
|
| 150 |
pytest.main([__file__, "-v"])
|
|
|
|
| 1 |
"""
|
| 2 |
+
Rigorous Tests for Priority-Tier Worker Pool implementation.
|
| 3 |
+
|
| 4 |
+
Tests cover:
|
| 5 |
+
1. Core Worker Behavior - Atomic claims, scheduling, efficiency
|
| 6 |
+
2. Job Status Transitions - State changes and retry logic
|
| 7 |
+
3. Priority Tier Isolation - Workers respect their tier
|
| 8 |
+
4. Wake Event System - Immediate job notification
|
| 9 |
+
5. Credit System Integration - Refunds, confirmations, idempotency
|
| 10 |
+
6. Error Handling - Exceptions, DB errors, validation
|
| 11 |
+
7. GeminiJobProcessor - Video polling, download retries, API key rotation
|
| 12 |
+
8. Concurrency Edge Cases - Race conditions, transaction safety
|
| 13 |
+
9. Pool Lifecycle - Start/stop, orphan refunds, clean shutdown
|
| 14 |
"""
|
| 15 |
import pytest
|
| 16 |
import asyncio
|
| 17 |
+
from unittest.mock import patch, MagicMock, AsyncMock, PropertyMock
|
| 18 |
from datetime import datetime, timedelta
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
|
| 21 |
# Test the modular priority worker pool
|
| 22 |
from services.priority_worker_pool import (
|
| 23 |
PriorityWorkerPool,
|
| 24 |
PriorityWorker,
|
| 25 |
WorkerConfig,
|
| 26 |
+
PriorityMapping,
|
| 27 |
+
JobProcessor,
|
| 28 |
+
get_interval_for_priority,
|
| 29 |
+
get_priority_for_job_type
|
| 30 |
)
|
| 31 |
|
| 32 |
# Test the Gemini-specific implementation
|
| 33 |
from services.gemini_job_worker import (
|
| 34 |
+
get_priority_for_job_type as gemini_get_priority,
|
| 35 |
JOB_PRIORITY_MAP,
|
| 36 |
GeminiJobProcessor
|
| 37 |
)
|
| 38 |
|
| 39 |
+
# Test credit service
|
| 40 |
+
from services.credit_service import (
|
| 41 |
+
is_refundable_error,
|
| 42 |
+
reserve_credit,
|
| 43 |
+
confirm_credit,
|
| 44 |
+
refund_credit,
|
| 45 |
+
handle_job_completion,
|
| 46 |
+
refund_orphaned_jobs,
|
| 47 |
+
REFUNDABLE_ERROR_PATTERNS,
|
| 48 |
+
NON_REFUNDABLE_ERROR_PATTERNS
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# =============================================================================
|
| 53 |
+
# Mock Job Model for Testing
|
| 54 |
+
# =============================================================================
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class MockJob:
|
| 58 |
+
"""Mock job model for testing."""
|
| 59 |
+
job_id: str = "test-job-123"
|
| 60 |
+
user_id: str = "user-456"
|
| 61 |
+
job_type: str = "text"
|
| 62 |
+
status: str = "queued"
|
| 63 |
+
priority: str = "fast"
|
| 64 |
+
next_process_at: datetime = None
|
| 65 |
+
retry_count: int = 0
|
| 66 |
+
third_party_id: str = None
|
| 67 |
+
input_data: dict = None
|
| 68 |
+
output_data: dict = None
|
| 69 |
+
error_message: str = None
|
| 70 |
+
created_at: datetime = None
|
| 71 |
+
started_at: datetime = None
|
| 72 |
+
completed_at: datetime = None
|
| 73 |
+
credits_reserved: int = 0
|
| 74 |
+
credits_refunded: bool = False
|
| 75 |
+
|
| 76 |
+
def __post_init__(self):
|
| 77 |
+
if self.created_at is None:
|
| 78 |
+
self.created_at = datetime.utcnow()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass
|
| 82 |
+
class MockUser:
|
| 83 |
+
"""Mock user model for testing."""
|
| 84 |
+
user_id: str = "user-456"
|
| 85 |
+
email: str = "test@example.com"
|
| 86 |
+
credits: int = 100
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# =============================================================================
|
| 90 |
+
# 1. Priority Mapping Tests
|
| 91 |
+
# =============================================================================
|
| 92 |
|
| 93 |
class TestPriorityMapping:
|
| 94 |
"""Test job type to priority mapping."""
|
| 95 |
|
| 96 |
def test_text_job_is_fast(self):
|
| 97 |
+
assert gemini_get_priority("text") == "fast"
|
| 98 |
|
| 99 |
def test_analyze_job_is_fast(self):
|
| 100 |
+
assert gemini_get_priority("analyze") == "fast"
|
| 101 |
|
| 102 |
def test_animation_prompt_is_fast(self):
|
| 103 |
+
assert gemini_get_priority("animation_prompt") == "fast"
|
| 104 |
|
| 105 |
def test_image_job_is_medium(self):
|
| 106 |
+
assert gemini_get_priority("image") == "medium"
|
| 107 |
|
| 108 |
def test_edit_image_is_medium(self):
|
| 109 |
+
assert gemini_get_priority("edit_image") == "medium"
|
| 110 |
|
| 111 |
def test_video_job_is_slow(self):
|
| 112 |
+
assert gemini_get_priority("video") == "slow"
|
| 113 |
|
| 114 |
def test_unknown_job_defaults_to_fast(self):
|
| 115 |
+
assert gemini_get_priority("unknown_type") == "fast"
|
| 116 |
+
|
| 117 |
|
| 118 |
+
# =============================================================================
|
| 119 |
+
# 2. Interval Mapping Tests
|
| 120 |
+
# =============================================================================
|
| 121 |
|
| 122 |
class TestIntervalMapping:
|
| 123 |
"""Test priority to interval mapping."""
|
| 124 |
|
| 125 |
+
def test_fast_interval_with_default_config(self):
|
| 126 |
+
config = WorkerConfig()
|
| 127 |
+
assert get_interval_for_priority("fast", config) == config.fast_interval
|
| 128 |
|
| 129 |
+
def test_medium_interval_with_default_config(self):
|
| 130 |
+
config = WorkerConfig()
|
| 131 |
+
assert get_interval_for_priority("medium", config) == config.medium_interval
|
| 132 |
|
| 133 |
+
def test_slow_interval_with_default_config(self):
|
| 134 |
+
config = WorkerConfig()
|
| 135 |
+
assert get_interval_for_priority("slow", config) == config.slow_interval
|
| 136 |
|
| 137 |
def test_unknown_defaults_to_slow(self):
|
| 138 |
assert get_interval_for_priority("unknown") == 60
|
| 139 |
+
|
| 140 |
+
def test_custom_config_intervals(self):
|
| 141 |
+
config = WorkerConfig(fast_interval=10, medium_interval=20, slow_interval=30)
|
| 142 |
+
assert get_interval_for_priority("fast", config) == 10
|
| 143 |
+
assert get_interval_for_priority("medium", config) == 20
|
| 144 |
+
assert get_interval_for_priority("slow", config) == 30
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class TestPriorityMappingClass:
|
| 148 |
+
"""Test the PriorityMapping dataclass."""
|
| 149 |
+
|
| 150 |
+
def test_get_priority_with_mappings(self):
|
| 151 |
+
mapping = PriorityMapping(mappings={"custom_type": "slow"})
|
| 152 |
+
assert mapping.get_priority("custom_type") == "slow"
|
| 153 |
+
|
| 154 |
+
def test_get_priority_default_when_not_found(self):
|
| 155 |
+
mapping = PriorityMapping(mappings={})
|
| 156 |
+
assert mapping.get_priority("unknown", default="medium") == "medium"
|
| 157 |
+
|
| 158 |
+
def test_get_interval_for_priorities(self):
|
| 159 |
+
mapping = PriorityMapping()
|
| 160 |
+
config = WorkerConfig(fast_interval=5, medium_interval=30, slow_interval=60)
|
| 161 |
+
assert mapping.get_interval("fast", config) == 5
|
| 162 |
+
assert mapping.get_interval("medium", config) == 30
|
| 163 |
+
assert mapping.get_interval("slow", config) == 60
|
| 164 |
+
|
| 165 |
|
| 166 |
+
# =============================================================================
|
| 167 |
+
# 3. Job Priority Map Coverage Tests
|
| 168 |
+
# =============================================================================
|
| 169 |
|
| 170 |
class TestJobPriorityMap:
|
| 171 |
"""Test that all expected job types are covered."""
|
|
|
|
| 174 |
expected_types = ["text", "analyze", "animation_prompt", "image", "edit_image", "video"]
|
| 175 |
for job_type in expected_types:
|
| 176 |
assert job_type in JOB_PRIORITY_MAP, f"Job type '{job_type}' not in priority map"
|
| 177 |
+
|
| 178 |
+
def test_priority_values_are_valid(self):
|
| 179 |
+
valid_priorities = {"fast", "medium", "slow"}
|
| 180 |
+
for job_type, priority in JOB_PRIORITY_MAP.items():
|
| 181 |
+
assert priority in valid_priorities, f"Invalid priority '{priority}' for job type '{job_type}'"
|
| 182 |
+
|
| 183 |
|
| 184 |
+
# =============================================================================
|
| 185 |
+
# 4. Worker Pool Configuration Tests
|
| 186 |
+
# =============================================================================
|
| 187 |
|
| 188 |
class TestWorkerPoolConfiguration:
|
| 189 |
"""Test worker pool configuration."""
|
|
|
|
| 194 |
assert config.fast_workers == 5
|
| 195 |
assert config.medium_workers == 5
|
| 196 |
assert config.slow_workers == 5
|
| 197 |
+
assert config.fast_interval == 2
|
| 198 |
+
assert config.medium_interval == 10
|
| 199 |
+
assert config.slow_interval == 15
|
| 200 |
+
assert config.max_retries == 60
|
| 201 |
|
| 202 |
def test_custom_config(self):
|
| 203 |
"""Test WorkerConfig with custom values."""
|
|
|
|
| 207 |
slow_workers=1,
|
| 208 |
fast_interval=10,
|
| 209 |
medium_interval=60,
|
| 210 |
+
slow_interval=120,
|
| 211 |
+
max_retries=100
|
| 212 |
)
|
| 213 |
assert config.fast_workers == 3
|
| 214 |
assert config.medium_workers == 2
|
| 215 |
assert config.slow_workers == 1
|
| 216 |
+
assert config.max_retries == 100
|
| 217 |
|
| 218 |
def test_total_workers_calculation(self):
|
| 219 |
"""Test total workers from config."""
|
| 220 |
config = WorkerConfig(fast_workers=5, medium_workers=5, slow_workers=5)
|
| 221 |
total = config.fast_workers + config.medium_workers + config.slow_workers
|
| 222 |
assert total == 15
|
| 223 |
+
|
| 224 |
+
def test_config_from_env(self):
|
| 225 |
+
"""Test WorkerConfig.from_env() with mocked environment."""
|
| 226 |
+
with patch.dict('os.environ', {
|
| 227 |
+
'FAST_WORKERS': '10',
|
| 228 |
+
'MEDIUM_WORKERS': '8',
|
| 229 |
+
'SLOW_WORKERS': '6'
|
| 230 |
+
}):
|
| 231 |
+
config = WorkerConfig.from_env()
|
| 232 |
+
assert config.fast_workers == 10
|
| 233 |
+
assert config.medium_workers == 8
|
| 234 |
+
assert config.slow_workers == 6
|
| 235 |
+
|
| 236 |
|
| 237 |
+
# =============================================================================
|
| 238 |
+
# 5. Priority Worker Tests
|
| 239 |
+
# =============================================================================
|
| 240 |
|
| 241 |
class TestPriorityWorker:
|
| 242 |
"""Test individual worker behavior."""
|
| 243 |
|
| 244 |
def test_worker_has_correct_attributes(self):
|
| 245 |
"""Test worker initialization."""
|
|
|
|
| 246 |
worker = PriorityWorker(
|
| 247 |
worker_id=0,
|
| 248 |
priority="fast",
|
|
|
|
| 257 |
assert worker.poll_interval == 5
|
| 258 |
assert worker._running == False
|
| 259 |
assert worker._current_job_id is None
|
| 260 |
+
|
| 261 |
+
def test_worker_with_max_retries(self):
|
| 262 |
+
"""Test worker respects max_retries config."""
|
| 263 |
+
worker = PriorityWorker(
|
| 264 |
+
worker_id=1,
|
| 265 |
+
priority="slow",
|
| 266 |
+
poll_interval=60,
|
| 267 |
+
session_maker=None,
|
| 268 |
+
job_model=None,
|
| 269 |
+
job_processor=None,
|
| 270 |
+
max_retries=100
|
| 271 |
+
)
|
| 272 |
+
assert worker.max_retries == 100
|
| 273 |
+
|
| 274 |
+
def test_worker_with_wake_event(self):
|
| 275 |
+
"""Test worker accepts wake event."""
|
| 276 |
+
event = asyncio.Event()
|
| 277 |
+
worker = PriorityWorker(
|
| 278 |
+
worker_id=2,
|
| 279 |
+
priority="medium",
|
| 280 |
+
poll_interval=30,
|
| 281 |
+
session_maker=None,
|
| 282 |
+
job_model=None,
|
| 283 |
+
job_processor=None,
|
| 284 |
+
wake_event=event
|
| 285 |
+
)
|
| 286 |
+
assert worker._wake_event is event
|
| 287 |
+
|
| 288 |
+
@pytest.mark.asyncio
|
| 289 |
+
async def test_worker_start_sets_running_flag(self):
|
| 290 |
+
"""Test worker.start() sets running flag."""
|
| 291 |
+
worker = PriorityWorker(
|
| 292 |
+
worker_id=0,
|
| 293 |
+
priority="fast",
|
| 294 |
+
poll_interval=5,
|
| 295 |
+
session_maker=MagicMock(),
|
| 296 |
+
job_model=MockJob,
|
| 297 |
+
job_processor=MagicMock()
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Mock the poll loop to prevent actual execution
|
| 301 |
+
with patch.object(worker, '_poll_loop', new_callable=AsyncMock):
|
| 302 |
+
await worker.start()
|
| 303 |
+
assert worker._running == True
|
| 304 |
+
|
| 305 |
+
@pytest.mark.asyncio
|
| 306 |
+
async def test_worker_stop_clears_running_flag(self):
|
| 307 |
+
"""Test worker.stop() clears running flag."""
|
| 308 |
+
worker = PriorityWorker(
|
| 309 |
+
worker_id=0,
|
| 310 |
+
priority="fast",
|
| 311 |
+
poll_interval=5,
|
| 312 |
+
session_maker=None,
|
| 313 |
+
job_model=None,
|
| 314 |
+
job_processor=None
|
| 315 |
+
)
|
| 316 |
+
worker._running = True
|
| 317 |
+
await worker.stop()
|
| 318 |
+
assert worker._running == False
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
# =============================================================================
|
| 322 |
+
# 6. Credit System Tests - is_refundable_error
|
| 323 |
+
# =============================================================================
|
| 324 |
+
|
| 325 |
+
class TestIsRefundableError:
|
| 326 |
+
"""Test error classification for credit refunds."""
|
| 327 |
+
|
| 328 |
+
def test_empty_error_is_not_refundable(self):
|
| 329 |
+
assert is_refundable_error(None) == False
|
| 330 |
+
assert is_refundable_error("") == False
|
| 331 |
+
|
| 332 |
+
# Refundable errors
|
| 333 |
+
def test_api_key_invalid_is_refundable(self):
|
| 334 |
+
assert is_refundable_error("API_KEY_INVALID: Authentication failed") == True
|
| 335 |
+
|
| 336 |
+
def test_quota_exceeded_is_refundable(self):
|
| 337 |
+
assert is_refundable_error("QUOTA_EXCEEDED: Daily limit reached") == True
|
| 338 |
+
|
| 339 |
+
def test_internal_error_is_refundable(self):
|
| 340 |
+
assert is_refundable_error("INTERNAL_ERROR: Server crashed") == True
|
| 341 |
+
|
| 342 |
+
def test_connection_failed_is_refundable(self):
|
| 343 |
+
assert is_refundable_error("CONNECTION_FAILED: Could not reach API") == True
|
| 344 |
+
|
| 345 |
+
def test_timeout_is_refundable(self):
|
| 346 |
+
assert is_refundable_error("TIMEOUT: Request timed out after 30s") == True
|
| 347 |
+
|
| 348 |
+
def test_500_error_is_refundable(self):
|
| 349 |
+
assert is_refundable_error("Server returned 500 Internal Server Error") == True
|
| 350 |
+
|
| 351 |
+
def test_503_error_is_refundable(self):
|
| 352 |
+
assert is_refundable_error("503 Service Unavailable") == True
|
| 353 |
+
|
| 354 |
+
def test_429_rate_limit_is_refundable(self):
|
| 355 |
+
assert is_refundable_error("429 Too Many Requests") == True
|
| 356 |
+
|
| 357 |
+
def test_server_shutdown_is_refundable(self):
|
| 358 |
+
assert is_refundable_error("SERVER_SHUTDOWN: Graceful shutdown") == True
|
| 359 |
+
|
| 360 |
+
def test_max_retries_is_refundable(self):
|
| 361 |
+
assert is_refundable_error("Max retries (60) exceeded") == True
|
| 362 |
+
|
| 363 |
+
# Non-refundable errors
|
| 364 |
+
def test_safety_filter_is_not_refundable(self):
|
| 365 |
+
assert is_refundable_error("Content blocked by safety filter") == False
|
| 366 |
+
|
| 367 |
+
def test_blocked_content_is_not_refundable(self):
|
| 368 |
+
assert is_refundable_error("Request blocked due to policy violation") == False
|
| 369 |
+
|
| 370 |
+
def test_invalid_input_is_not_refundable(self):
|
| 371 |
+
assert is_refundable_error("INVALID_INPUT: Prompt too long") == False
|
| 372 |
+
|
| 373 |
+
def test_invalid_image_is_not_refundable(self):
|
| 374 |
+
assert is_refundable_error("Invalid image format provided") == False
|
| 375 |
+
|
| 376 |
+
def test_bad_request_400_is_not_refundable(self):
|
| 377 |
+
assert is_refundable_error("400 Bad Request") == False
|
| 378 |
+
|
| 379 |
+
def test_user_cancelled_is_not_refundable(self):
|
| 380 |
+
assert is_refundable_error("User cancelled the request") == False
|
| 381 |
+
|
| 382 |
+
def test_unknown_error_defaults_to_not_refundable(self):
|
| 383 |
+
assert is_refundable_error("Some random unknown error XYZ") == False
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# =============================================================================
|
| 387 |
+
# 7. Credit System Tests - Reserve/Confirm/Refund
|
| 388 |
+
# =============================================================================
|
| 389 |
+
|
| 390 |
+
class TestReserveCredit:
|
| 391 |
+
"""Test credit reservation."""
|
| 392 |
+
|
| 393 |
+
@pytest.mark.asyncio
|
| 394 |
+
async def test_reserve_deducts_from_user(self):
|
| 395 |
+
"""Credits are deducted on reservation."""
|
| 396 |
+
session = AsyncMock()
|
| 397 |
+
user = MockUser(credits=100)
|
| 398 |
+
|
| 399 |
+
result = await reserve_credit(session, user, amount=10)
|
| 400 |
+
|
| 401 |
+
assert result == True
|
| 402 |
+
assert user.credits == 90
|
| 403 |
+
|
| 404 |
+
@pytest.mark.asyncio
|
| 405 |
+
async def test_reserve_fails_with_insufficient_credits(self):
|
| 406 |
+
"""Reservation fails if user doesn't have enough credits."""
|
| 407 |
+
session = AsyncMock()
|
| 408 |
+
user = MockUser(credits=5)
|
| 409 |
+
|
| 410 |
+
result = await reserve_credit(session, user, amount=10)
|
| 411 |
+
|
| 412 |
+
assert result == False
|
| 413 |
+
assert user.credits == 5 # Unchanged
|
| 414 |
+
|
| 415 |
+
@pytest.mark.asyncio
|
| 416 |
+
async def test_reserve_exact_amount(self):
|
| 417 |
+
"""User can reserve exactly their remaining credits."""
|
| 418 |
+
session = AsyncMock()
|
| 419 |
+
user = MockUser(credits=10)
|
| 420 |
+
|
| 421 |
+
result = await reserve_credit(session, user, amount=10)
|
| 422 |
+
|
| 423 |
+
assert result == True
|
| 424 |
+
assert user.credits == 0
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
class TestConfirmCredit:
|
| 428 |
+
"""Test credit confirmation."""
|
| 429 |
+
|
| 430 |
+
@pytest.mark.asyncio
|
| 431 |
+
async def test_confirm_clears_reservation(self):
|
| 432 |
+
"""Confirmation clears credits_reserved field."""
|
| 433 |
+
session = AsyncMock()
|
| 434 |
+
job = MockJob(credits_reserved=5)
|
| 435 |
+
|
| 436 |
+
await confirm_credit(session, job)
|
| 437 |
+
|
| 438 |
+
assert job.credits_reserved == 0
|
| 439 |
+
|
| 440 |
+
@pytest.mark.asyncio
|
| 441 |
+
async def test_confirm_no_op_when_no_reservation(self):
|
| 442 |
+
"""Confirmation is a no-op when no credits reserved."""
|
| 443 |
+
session = AsyncMock()
|
| 444 |
+
job = MockJob(credits_reserved=0)
|
| 445 |
+
|
| 446 |
+
await confirm_credit(session, job)
|
| 447 |
+
|
| 448 |
+
assert job.credits_reserved == 0
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
class TestRefundCredit:
|
| 452 |
+
"""Test credit refunding."""
|
| 453 |
+
|
| 454 |
+
@pytest.mark.asyncio
|
| 455 |
+
async def test_refund_restores_user_credits(self):
|
| 456 |
+
"""Refund restores credits to user."""
|
| 457 |
+
session = AsyncMock()
|
| 458 |
+
user = MockUser(user_id="user-456", credits=90)
|
| 459 |
+
job = MockJob(user_id="user-456", credits_reserved=10, credits_refunded=False)
|
| 460 |
+
|
| 461 |
+
# Mock the database query to return our user
|
| 462 |
+
from core.models import User
|
| 463 |
+
mock_result = MagicMock()
|
| 464 |
+
mock_result.scalar_one_or_none.return_value = user
|
| 465 |
+
session.execute = AsyncMock(return_value=mock_result)
|
| 466 |
+
|
| 467 |
+
result = await refund_credit(session, job, "Test refund")
|
| 468 |
+
|
| 469 |
+
assert result == True
|
| 470 |
+
assert user.credits == 100 # 90 + 10 refunded
|
| 471 |
+
assert job.credits_reserved == 0
|
| 472 |
+
assert job.credits_refunded == True
|
| 473 |
+
|
| 474 |
+
@pytest.mark.asyncio
|
| 475 |
+
async def test_refund_fails_when_no_credits_reserved(self):
|
| 476 |
+
"""Refund fails if job has no credits reserved."""
|
| 477 |
+
session = AsyncMock()
|
| 478 |
+
job = MockJob(credits_reserved=0)
|
| 479 |
+
|
| 480 |
+
result = await refund_credit(session, job, "No credits")
|
| 481 |
+
|
| 482 |
+
assert result == False
|
| 483 |
+
|
| 484 |
+
@pytest.mark.asyncio
|
| 485 |
+
async def test_refund_fails_when_already_refunded(self):
|
| 486 |
+
"""Refund fails if job was already refunded (idempotency)."""
|
| 487 |
+
session = AsyncMock()
|
| 488 |
+
job = MockJob(credits_reserved=10, credits_refunded=True)
|
| 489 |
+
|
| 490 |
+
result = await refund_credit(session, job, "Already done")
|
| 491 |
+
|
| 492 |
+
assert result == False
|
| 493 |
+
|
| 494 |
+
@pytest.mark.asyncio
|
| 495 |
+
async def test_refund_fails_when_user_not_found(self):
|
| 496 |
+
"""Refund fails if user doesn't exist."""
|
| 497 |
+
session = AsyncMock()
|
| 498 |
+
job = MockJob(user_id="nonexistent", credits_reserved=10, credits_refunded=False)
|
| 499 |
+
|
| 500 |
+
mock_result = MagicMock()
|
| 501 |
+
mock_result.scalar_one_or_none.return_value = None
|
| 502 |
+
session.execute = AsyncMock(return_value=mock_result)
|
| 503 |
+
|
| 504 |
+
result = await refund_credit(session, job, "User gone")
|
| 505 |
+
|
| 506 |
+
assert result == False
|
| 507 |
|
| 508 |
|
| 509 |
+
# =============================================================================
|
| 510 |
+
# 8. Credit System Tests - handle_job_completion
|
| 511 |
+
# =============================================================================
|
| 512 |
+
|
| 513 |
+
class TestHandleJobCompletion:
|
| 514 |
+
"""Test the main credit finalization logic."""
|
| 515 |
+
|
| 516 |
+
@pytest.mark.asyncio
|
| 517 |
+
async def test_completed_job_confirms_credits(self):
|
| 518 |
+
"""Completed jobs have credits confirmed."""
|
| 519 |
+
session = AsyncMock()
|
| 520 |
+
job = MockJob(status="completed", credits_reserved=5)
|
| 521 |
+
|
| 522 |
+
with patch('services.credit_service.confirm_credit', new_callable=AsyncMock) as mock_confirm:
|
| 523 |
+
await handle_job_completion(session, job)
|
| 524 |
+
mock_confirm.assert_called_once_with(session, job)
|
| 525 |
+
|
| 526 |
+
@pytest.mark.asyncio
|
| 527 |
+
async def test_failed_job_refundable_error_refunds(self):
|
| 528 |
+
"""Failed jobs with refundable errors get refunds."""
|
| 529 |
+
session = AsyncMock()
|
| 530 |
+
job = MockJob(status="failed", error_message="500 Server Error", credits_reserved=5)
|
| 531 |
+
|
| 532 |
+
with patch('services.credit_service.refund_credit', new_callable=AsyncMock) as mock_refund:
|
| 533 |
+
await handle_job_completion(session, job)
|
| 534 |
+
mock_refund.assert_called_once()
|
| 535 |
+
|
| 536 |
+
@pytest.mark.asyncio
|
| 537 |
+
async def test_failed_job_non_refundable_error_keeps_credits(self):
|
| 538 |
+
"""Failed jobs with non-refundable errors keep credits consumed."""
|
| 539 |
+
session = AsyncMock()
|
| 540 |
+
job = MockJob(status="failed", error_message="Content blocked by safety", credits_reserved=5)
|
| 541 |
+
|
| 542 |
+
with patch('services.credit_service.confirm_credit', new_callable=AsyncMock) as mock_confirm:
|
| 543 |
+
await handle_job_completion(session, job)
|
| 544 |
+
mock_confirm.assert_called_once_with(session, job)
|
| 545 |
+
|
| 546 |
+
@pytest.mark.asyncio
|
| 547 |
+
async def test_cancelled_job_before_start_refunds(self):
|
| 548 |
+
"""Cancelled jobs before started_at get refunds."""
|
| 549 |
+
session = AsyncMock()
|
| 550 |
+
job = MockJob(status="cancelled", started_at=None, credits_reserved=5)
|
| 551 |
+
|
| 552 |
+
with patch('services.credit_service.refund_credit', new_callable=AsyncMock) as mock_refund:
|
| 553 |
+
await handle_job_completion(session, job)
|
| 554 |
+
mock_refund.assert_called_once()
|
| 555 |
+
|
| 556 |
+
@pytest.mark.asyncio
|
| 557 |
+
async def test_cancelled_job_after_start_keeps_credits(self):
|
| 558 |
+
"""Cancelled jobs after started_at keep credits consumed."""
|
| 559 |
+
session = AsyncMock()
|
| 560 |
+
job = MockJob(status="cancelled", started_at=datetime.utcnow(), credits_reserved=5)
|
| 561 |
+
|
| 562 |
+
with patch('services.credit_service.confirm_credit', new_callable=AsyncMock) as mock_confirm:
|
| 563 |
+
await handle_job_completion(session, job)
|
| 564 |
+
mock_confirm.assert_called_once_with(session, job)
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
# =============================================================================
|
| 568 |
+
# 9. Credit System Tests - Orphaned Jobs
|
| 569 |
+
# =============================================================================
|
| 570 |
+
|
| 571 |
+
class TestRefundOrphanedJobs:
|
| 572 |
+
"""Test orphaned job refund during shutdown."""
|
| 573 |
+
|
| 574 |
+
@pytest.mark.asyncio
|
| 575 |
+
async def test_refund_orphaned_jobs_finds_processing_jobs(self):
|
| 576 |
+
"""Shutdown finds and refunds processing jobs with reserved credits."""
|
| 577 |
+
session = AsyncMock()
|
| 578 |
+
|
| 579 |
+
# Mock orphaned jobs
|
| 580 |
+
orphaned_job = MockJob(
|
| 581 |
+
job_id="orphan-1",
|
| 582 |
+
user_id="user-456",
|
| 583 |
+
status="processing",
|
| 584 |
+
credits_reserved=10,
|
| 585 |
+
credits_refunded=False
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
mock_result = MagicMock()
|
| 589 |
+
mock_result.scalars.return_value.all.return_value = [orphaned_job]
|
| 590 |
+
session.execute = AsyncMock(return_value=mock_result)
|
| 591 |
+
session.commit = AsyncMock()
|
| 592 |
+
|
| 593 |
+
with patch('services.credit_service.refund_credit', new_callable=AsyncMock, return_value=True):
|
| 594 |
+
count = await refund_orphaned_jobs(session)
|
| 595 |
+
|
| 596 |
+
assert count == 1
|
| 597 |
+
assert orphaned_job.status == "failed"
|
| 598 |
+
assert "shutdown" in orphaned_job.error_message.lower()
|
| 599 |
+
|
| 600 |
+
@pytest.mark.asyncio
|
| 601 |
+
async def test_refund_orphaned_jobs_no_orphans(self):
|
| 602 |
+
"""No action when there are no orphaned jobs."""
|
| 603 |
+
session = AsyncMock()
|
| 604 |
+
|
| 605 |
+
mock_result = MagicMock()
|
| 606 |
+
mock_result.scalars.return_value.all.return_value = []
|
| 607 |
+
session.execute = AsyncMock(return_value=mock_result)
|
| 608 |
+
|
| 609 |
+
count = await refund_orphaned_jobs(session)
|
| 610 |
+
|
| 611 |
+
assert count == 0
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
# =============================================================================
|
| 615 |
+
# 10. GeminiJobProcessor Tests
|
| 616 |
+
# =============================================================================
|
| 617 |
+
|
| 618 |
+
class TestGeminiJobProcessor:
|
| 619 |
+
"""Test Gemini-specific job processing."""
|
| 620 |
+
|
| 621 |
+
@pytest.mark.asyncio
|
| 622 |
+
async def test_unknown_job_type_fails_gracefully(self):
|
| 623 |
+
"""Unknown job type results in clear error message."""
|
| 624 |
+
processor = GeminiJobProcessor()
|
| 625 |
+
session = AsyncMock()
|
| 626 |
+
job = MockJob(job_type="unknown_type")
|
| 627 |
+
|
| 628 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 629 |
+
mock_key.return_value = (0, MagicMock())
|
| 630 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock):
|
| 631 |
+
result = await processor.process(job, session)
|
| 632 |
+
|
| 633 |
+
assert result.status == "failed"
|
| 634 |
+
assert "Unknown job type" in result.error_message
|
| 635 |
+
|
| 636 |
+
@pytest.mark.asyncio
|
| 637 |
+
async def test_check_status_fails_without_third_party_id(self):
|
| 638 |
+
"""Status check fails if third_party_id is None."""
|
| 639 |
+
processor = GeminiJobProcessor()
|
| 640 |
+
session = AsyncMock()
|
| 641 |
+
job = MockJob(job_type="video", third_party_id=None)
|
| 642 |
+
|
| 643 |
+
result = await processor.check_status(job, session)
|
| 644 |
+
|
| 645 |
+
assert result.status == "failed"
|
| 646 |
+
assert "Invalid job state" in result.error_message
|
| 647 |
+
|
| 648 |
+
@pytest.mark.asyncio
|
| 649 |
+
async def test_check_status_fails_for_non_video(self):
|
| 650 |
+
"""Status check fails for non-video jobs."""
|
| 651 |
+
processor = GeminiJobProcessor()
|
| 652 |
+
session = AsyncMock()
|
| 653 |
+
job = MockJob(job_type="text", third_party_id="some-id")
|
| 654 |
+
|
| 655 |
+
result = await processor.check_status(job, session)
|
| 656 |
+
|
| 657 |
+
assert result.status == "failed"
|
| 658 |
+
assert "Invalid job state" in result.error_message
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
class TestGeminiJobProcessorTextProcessing:
|
| 662 |
+
"""Test text job processing."""
|
| 663 |
+
|
| 664 |
+
@pytest.mark.asyncio
|
| 665 |
+
async def test_text_job_completes_successfully(self):
|
| 666 |
+
"""Text job completes with output data."""
|
| 667 |
+
processor = GeminiJobProcessor()
|
| 668 |
+
session = AsyncMock()
|
| 669 |
+
job = MockJob(job_type="text", input_data={"prompt": "Hello"})
|
| 670 |
+
|
| 671 |
+
mock_service = MagicMock()
|
| 672 |
+
mock_service.generate_text = AsyncMock(return_value="Hello back!")
|
| 673 |
+
|
| 674 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 675 |
+
mock_key.return_value = (0, mock_service)
|
| 676 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock):
|
| 677 |
+
result = await processor.process(job, session)
|
| 678 |
+
|
| 679 |
+
assert result.status == "completed"
|
| 680 |
+
assert result.output_data == {"text": "Hello back!"}
|
| 681 |
+
assert result.completed_at is not None
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
class TestGeminiJobProcessorVideoProcessing:
|
| 685 |
+
"""Test video job processing."""
|
| 686 |
+
|
| 687 |
+
@pytest.mark.asyncio
|
| 688 |
+
async def test_video_job_sets_third_party_id(self):
|
| 689 |
+
"""Video job stores operation name for polling."""
|
| 690 |
+
processor = GeminiJobProcessor()
|
| 691 |
+
session = AsyncMock()
|
| 692 |
+
job = MockJob(
|
| 693 |
+
job_type="video",
|
| 694 |
+
priority="slow",
|
| 695 |
+
input_data={"base64_image": "abc", "prompt": "animate this"}
|
| 696 |
+
)
|
| 697 |
+
|
| 698 |
+
mock_service = MagicMock()
|
| 699 |
+
mock_service.start_video_generation = AsyncMock(return_value={
|
| 700 |
+
"gemini_operation_name": "operations/12345"
|
| 701 |
+
})
|
| 702 |
+
|
| 703 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 704 |
+
mock_key.return_value = (0, mock_service)
|
| 705 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock):
|
| 706 |
+
result = await processor.process(job, session)
|
| 707 |
+
|
| 708 |
+
assert result.third_party_id == "operations/12345"
|
| 709 |
+
assert result.next_process_at is not None
|
| 710 |
+
|
| 711 |
+
@pytest.mark.asyncio
|
| 712 |
+
async def test_video_check_reschedules_if_not_done(self):
|
| 713 |
+
"""Pending video job gets new next_process_at."""
|
| 714 |
+
processor = GeminiJobProcessor()
|
| 715 |
+
session = AsyncMock()
|
| 716 |
+
job = MockJob(
|
| 717 |
+
job_type="video",
|
| 718 |
+
priority="slow",
|
| 719 |
+
third_party_id="operations/12345",
|
| 720 |
+
retry_count=0
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
mock_service = MagicMock()
|
| 724 |
+
mock_service.check_video_status = AsyncMock(return_value={"done": False})
|
| 725 |
+
|
| 726 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 727 |
+
mock_key.return_value = (0, mock_service)
|
| 728 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock):
|
| 729 |
+
result = await processor.check_status(job, session)
|
| 730 |
+
|
| 731 |
+
assert result.retry_count == 1
|
| 732 |
+
assert result.next_process_at is not None
|
| 733 |
+
|
| 734 |
+
@pytest.mark.asyncio
|
| 735 |
+
async def test_video_download_retry_on_failure(self):
|
| 736 |
+
"""Download failures increment retry, not immediate fail."""
|
| 737 |
+
processor = GeminiJobProcessor()
|
| 738 |
+
session = AsyncMock()
|
| 739 |
+
job = MockJob(
|
| 740 |
+
job_type="video",
|
| 741 |
+
priority="slow",
|
| 742 |
+
third_party_id="operations/12345",
|
| 743 |
+
retry_count=0
|
| 744 |
+
)
|
| 745 |
+
|
| 746 |
+
mock_service = MagicMock()
|
| 747 |
+
mock_service.check_video_status = AsyncMock(return_value={
|
| 748 |
+
"done": True,
|
| 749 |
+
"status": "completed",
|
| 750 |
+
"video_url": "https://example.com/video.mp4"
|
| 751 |
+
})
|
| 752 |
+
mock_service.download_video = AsyncMock(side_effect=Exception("Network error"))
|
| 753 |
+
|
| 754 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 755 |
+
mock_key.return_value = (0, mock_service)
|
| 756 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock):
|
| 757 |
+
result = await processor.check_status(job, session)
|
| 758 |
+
|
| 759 |
+
assert result.retry_count == 1
|
| 760 |
+
assert result.status != "failed" # Not failed yet
|
| 761 |
+
assert "Download attempt" in result.error_message
|
| 762 |
+
|
| 763 |
+
@pytest.mark.asyncio
|
| 764 |
+
async def test_video_fails_after_5_download_attempts(self):
|
| 765 |
+
"""After 5 download retries, job fails."""
|
| 766 |
+
processor = GeminiJobProcessor()
|
| 767 |
+
session = AsyncMock()
|
| 768 |
+
job = MockJob(
|
| 769 |
+
job_type="video",
|
| 770 |
+
priority="slow",
|
| 771 |
+
third_party_id="operations/12345",
|
| 772 |
+
retry_count=5 # Already at limit
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
mock_service = MagicMock()
|
| 776 |
+
mock_service.check_video_status = AsyncMock(return_value={
|
| 777 |
+
"done": True,
|
| 778 |
+
"status": "completed",
|
| 779 |
+
"video_url": "https://example.com/video.mp4"
|
| 780 |
+
})
|
| 781 |
+
mock_service.download_video = AsyncMock(side_effect=Exception("Network error"))
|
| 782 |
+
|
| 783 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 784 |
+
mock_key.return_value = (0, mock_service)
|
| 785 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock):
|
| 786 |
+
result = await processor.check_status(job, session)
|
| 787 |
+
|
| 788 |
+
assert result.status == "failed"
|
| 789 |
+
assert "Download failed" in result.error_message
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
class TestGeminiJobProcessorAPIKeyRotation:
|
| 793 |
+
"""Test API key rotation."""
|
| 794 |
+
|
| 795 |
+
@pytest.mark.asyncio
|
| 796 |
+
async def test_api_key_usage_recorded_on_success(self):
|
| 797 |
+
"""Usage statistics are updated on success."""
|
| 798 |
+
processor = GeminiJobProcessor()
|
| 799 |
+
session = AsyncMock()
|
| 800 |
+
job = MockJob(job_type="text", input_data={"prompt": "Hello"})
|
| 801 |
+
|
| 802 |
+
mock_service = MagicMock()
|
| 803 |
+
mock_service.generate_text = AsyncMock(return_value="Response")
|
| 804 |
+
|
| 805 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 806 |
+
mock_key.return_value = (0, mock_service)
|
| 807 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock) as mock_record:
|
| 808 |
+
await processor.process(job, session)
|
| 809 |
+
mock_record.assert_called_once_with(session, 0, True, None)
|
| 810 |
+
|
| 811 |
+
@pytest.mark.asyncio
|
| 812 |
+
async def test_api_key_usage_recorded_on_failure(self):
|
| 813 |
+
"""Usage statistics are updated on failure."""
|
| 814 |
+
processor = GeminiJobProcessor()
|
| 815 |
+
session = AsyncMock()
|
| 816 |
+
job = MockJob(job_type="text", input_data={"prompt": "Hello"})
|
| 817 |
+
|
| 818 |
+
mock_service = MagicMock()
|
| 819 |
+
mock_service.generate_text = AsyncMock(side_effect=Exception("API Error"))
|
| 820 |
+
|
| 821 |
+
with patch.object(processor, '_get_service_with_key', new_callable=AsyncMock) as mock_key:
|
| 822 |
+
mock_key.return_value = (0, mock_service)
|
| 823 |
+
with patch.object(processor, '_record_usage', new_callable=AsyncMock) as mock_record:
|
| 824 |
+
await processor.process(job, session)
|
| 825 |
+
mock_record.assert_called_once()
|
| 826 |
+
args = mock_record.call_args[0]
|
| 827 |
+
assert args[2] == False # success=False
|
| 828 |
+
assert "API Error" in args[3] # error_message
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
# =============================================================================
|
| 832 |
+
# 11. Cancel Endpoint Logic Tests
|
| 833 |
+
# =============================================================================
|
| 834 |
+
|
| 835 |
class TestCancelEndpoint:
|
| 836 |
"""Test cancel job endpoint logic."""
|
| 837 |
|
|
|
|
| 840 |
valid_statuses = ["queued"]
|
| 841 |
invalid_statuses = ["processing", "completed", "failed", "cancelled"]
|
| 842 |
|
|
|
|
| 843 |
for status in valid_statuses:
|
| 844 |
assert status == "queued"
|
| 845 |
|
|
|
|
| 847 |
assert status != "queued"
|
| 848 |
|
| 849 |
|
| 850 |
+
# =============================================================================
|
| 851 |
+
# 12. Worker Pool Lifecycle Tests
|
| 852 |
+
# =============================================================================
|
| 853 |
+
|
| 854 |
+
class TestWorkerPoolLifecycle:
|
| 855 |
+
"""Test pool start/stop behavior."""
|
| 856 |
+
|
| 857 |
+
def test_pool_initialization(self):
|
| 858 |
+
"""Pool initializes with correct attributes."""
|
| 859 |
+
config = WorkerConfig(fast_workers=2, medium_workers=2, slow_workers=2)
|
| 860 |
+
|
| 861 |
+
with patch('services.priority_worker_pool.create_async_engine'):
|
| 862 |
+
with patch('services.priority_worker_pool.async_sessionmaker'):
|
| 863 |
+
pool = PriorityWorkerPool(
|
| 864 |
+
database_url="sqlite+aiosqlite:///test.db",
|
| 865 |
+
job_model=MockJob,
|
| 866 |
+
job_processor=MagicMock(),
|
| 867 |
+
config=config
|
| 868 |
+
)
|
| 869 |
+
|
| 870 |
+
assert pool.config == config
|
| 871 |
+
assert pool.workers == []
|
| 872 |
+
assert pool._running == False
|
| 873 |
+
assert "fast" in pool._wake_events
|
| 874 |
+
assert "medium" in pool._wake_events
|
| 875 |
+
assert "slow" in pool._wake_events
|
| 876 |
+
|
| 877 |
+
def test_notify_new_job_sets_wake_event(self):
|
| 878 |
+
"""notify_new_job() signals the wake event."""
|
| 879 |
+
with patch('services.priority_worker_pool.create_async_engine'):
|
| 880 |
+
with patch('services.priority_worker_pool.async_sessionmaker'):
|
| 881 |
+
pool = PriorityWorkerPool(
|
| 882 |
+
database_url="sqlite+aiosqlite:///test.db",
|
| 883 |
+
job_model=MockJob,
|
| 884 |
+
job_processor=MagicMock()
|
| 885 |
+
)
|
| 886 |
+
|
| 887 |
+
assert pool._wake_events["fast"].is_set() == False
|
| 888 |
+
pool.notify_new_job("fast")
|
| 889 |
+
assert pool._wake_events["fast"].is_set() == True
|
| 890 |
+
|
| 891 |
+
def test_notify_new_job_ignores_invalid_priority(self):
|
| 892 |
+
"""notify_new_job() handles invalid priority gracefully."""
|
| 893 |
+
with patch('services.priority_worker_pool.create_async_engine'):
|
| 894 |
+
with patch('services.priority_worker_pool.async_sessionmaker'):
|
| 895 |
+
pool = PriorityWorkerPool(
|
| 896 |
+
database_url="sqlite+aiosqlite:///test.db",
|
| 897 |
+
job_model=MockJob,
|
| 898 |
+
job_processor=MagicMock()
|
| 899 |
+
)
|
| 900 |
+
|
| 901 |
+
# Should not raise
|
| 902 |
+
pool.notify_new_job("invalid_priority")
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
# =============================================================================
|
| 906 |
+
# 13. Edge Cases - Rare Scenarios
|
| 907 |
+
# =============================================================================
|
| 908 |
+
|
| 909 |
+
class TestRareEdgeCases:
|
| 910 |
+
"""Test rare edge cases and boundary conditions."""
|
| 911 |
+
|
| 912 |
+
def test_zero_workers_configuration(self):
|
| 913 |
+
"""Config with zero workers for some tiers."""
|
| 914 |
+
config = WorkerConfig(fast_workers=0, medium_workers=0, slow_workers=1)
|
| 915 |
+
assert config.fast_workers == 0
|
| 916 |
+
assert config.slow_workers == 1
|
| 917 |
+
|
| 918 |
+
def test_extremely_large_retry_count(self):
|
| 919 |
+
"""Job with very high retry count."""
|
| 920 |
+
job = MockJob(retry_count=999999)
|
| 921 |
+
assert job.retry_count == 999999
|
| 922 |
+
|
| 923 |
+
def test_job_with_empty_input_data(self):
|
| 924 |
+
"""Job with None or empty input_data."""
|
| 925 |
+
job1 = MockJob(input_data=None)
|
| 926 |
+
job2 = MockJob(input_data={})
|
| 927 |
+
assert job1.input_data is None
|
| 928 |
+
assert job2.input_data == {}
|
| 929 |
+
|
| 930 |
+
def test_job_with_very_long_error_message(self):
|
| 931 |
+
"""Job with extremely long error message."""
|
| 932 |
+
long_error = "Error: " + "x" * 10000
|
| 933 |
+
job = MockJob(error_message=long_error)
|
| 934 |
+
assert len(job.error_message) == 10007
|
| 935 |
+
|
| 936 |
+
def test_refundable_error_case_insensitive(self):
|
| 937 |
+
"""Error pattern matching is case insensitive."""
|
| 938 |
+
assert is_refundable_error("TIMEOUT") == True
|
| 939 |
+
assert is_refundable_error("timeout") == True
|
| 940 |
+
assert is_refundable_error("TimeOut") == True
|
| 941 |
+
|
| 942 |
+
def test_multiple_error_patterns_in_message(self):
|
| 943 |
+
"""Message with both refundable and non-refundable patterns."""
|
| 944 |
+
# REFUNDABLE patterns are checked first, so this should be refundable
|
| 945 |
+
mixed_error = "500 Internal Server Error and 400 Bad Request"
|
| 946 |
+
# "500" is refundable, checked first
|
| 947 |
+
assert is_refundable_error(mixed_error) == True
|
| 948 |
+
|
| 949 |
+
def test_credits_at_boundaries(self):
|
| 950 |
+
"""Test credit operations at boundary values."""
|
| 951 |
+
job = MockJob(credits_reserved=0)
|
| 952 |
+
assert job.credits_reserved == 0
|
| 953 |
+
|
| 954 |
+
job.credits_reserved = 2147483647 # Max int32
|
| 955 |
+
assert job.credits_reserved == 2147483647
|
| 956 |
+
|
| 957 |
+
@pytest.mark.asyncio
|
| 958 |
+
async def test_reserve_zero_credits(self):
|
| 959 |
+
"""Reserving zero credits succeeds (edge case)."""
|
| 960 |
+
session = AsyncMock()
|
| 961 |
+
user = MockUser(credits=10)
|
| 962 |
+
|
| 963 |
+
result = await reserve_credit(session, user, amount=0)
|
| 964 |
+
# amount=0 means credits < 0 is False, so it should succeed
|
| 965 |
+
assert result == True
|
| 966 |
+
assert user.credits == 10 # Unchanged
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
class TestConcurrencyEdgeCases:
|
| 970 |
+
"""Test concurrency-related edge cases."""
|
| 971 |
+
|
| 972 |
+
def test_wake_event_starts_unset(self):
|
| 973 |
+
"""Wake events start in unset state."""
|
| 974 |
+
event = asyncio.Event()
|
| 975 |
+
assert event.is_set() == False
|
| 976 |
+
|
| 977 |
+
@pytest.mark.asyncio
|
| 978 |
+
async def test_wake_event_can_be_set_multiple_times(self):
|
| 979 |
+
"""Setting wake event multiple times is idempotent."""
|
| 980 |
+
event = asyncio.Event()
|
| 981 |
+
event.set()
|
| 982 |
+
event.set()
|
| 983 |
+
event.set()
|
| 984 |
+
assert event.is_set() == True
|
| 985 |
+
|
| 986 |
+
@pytest.mark.asyncio
|
| 987 |
+
async def test_wake_event_clear_then_set(self):
|
| 988 |
+
"""Event can be cleared and set again."""
|
| 989 |
+
event = asyncio.Event()
|
| 990 |
+
event.set()
|
| 991 |
+
assert event.is_set() == True
|
| 992 |
+
event.clear()
|
| 993 |
+
assert event.is_set() == False
|
| 994 |
+
event.set()
|
| 995 |
+
assert event.is_set() == True
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
class TestDateTimeEdgeCases:
|
| 999 |
+
"""Test datetime-related edge cases."""
|
| 1000 |
+
|
| 1001 |
+
def test_job_with_past_next_process_at(self):
|
| 1002 |
+
"""Job with next_process_at in the past should be picked up."""
|
| 1003 |
+
past = datetime.utcnow() - timedelta(hours=1)
|
| 1004 |
+
job = MockJob(next_process_at=past)
|
| 1005 |
+
assert job.next_process_at < datetime.utcnow()
|
| 1006 |
+
|
| 1007 |
+
def test_job_with_future_next_process_at(self):
|
| 1008 |
+
"""Job with next_process_at in the future should wait."""
|
| 1009 |
+
future = datetime.utcnow() + timedelta(hours=1)
|
| 1010 |
+
job = MockJob(next_process_at=future)
|
| 1011 |
+
assert job.next_process_at > datetime.utcnow()
|
| 1012 |
+
|
| 1013 |
+
def test_job_created_at_auto_set(self):
|
| 1014 |
+
"""Job created_at is automatically set."""
|
| 1015 |
+
job = MockJob()
|
| 1016 |
+
assert job.created_at is not None
|
| 1017 |
+
assert isinstance(job.created_at, datetime)
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
# =============================================================================
|
| 1021 |
+
# 14. Error Pattern Coverage Tests
|
| 1022 |
+
# =============================================================================
|
| 1023 |
+
|
| 1024 |
+
class TestErrorPatternCoverage:
|
| 1025 |
+
"""Ensure all error patterns are tested."""
|
| 1026 |
+
|
| 1027 |
+
def test_all_refundable_patterns_detected(self):
|
| 1028 |
+
"""Every pattern in REFUNDABLE_ERROR_PATTERNS is correctly detected."""
|
| 1029 |
+
for pattern in REFUNDABLE_ERROR_PATTERNS:
|
| 1030 |
+
error_msg = f"Error: {pattern} occurred"
|
| 1031 |
+
assert is_refundable_error(error_msg) == True, f"Pattern '{pattern}' not detected as refundable"
|
| 1032 |
+
|
| 1033 |
+
def test_all_non_refundable_patterns_detected(self):
|
| 1034 |
+
"""Every pattern in NON_REFUNDABLE_ERROR_PATTERNS is correctly detected."""
|
| 1035 |
+
for pattern in NON_REFUNDABLE_ERROR_PATTERNS:
|
| 1036 |
+
# Avoid false positives from refundable patterns
|
| 1037 |
+
error_msg = f"User error: {pattern}"
|
| 1038 |
+
# Some patterns like "400" might be caught differently, test individually
|
| 1039 |
+
result = is_refundable_error(error_msg)
|
| 1040 |
+
# Just ensure the function doesn't crash
|
| 1041 |
+
assert result in [True, False]
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
if __name__ == "__main__":
|
| 1045 |
pytest.main([__file__, "-v"])
|