- T55: Setup APScheduler with AsyncIOScheduler and @scheduled_job decorator - T56: Implement hourly usage stats sync from OpenRouter API - T57: Implement daily API key validation job - T58: Implement weekly cleanup job for old usage stats - Add usage_stats_retention_days config option - Integrate scheduler with FastAPI lifespan events - Add 26 unit tests for scheduler, sync, and cleanup tasks - Add apscheduler to requirements.txt The background tasks now automatically: - Sync usage stats every hour from OpenRouter - Validate API keys daily at 2 AM UTC - Clean up old data weekly on Sunday at 3 AM UTC
215 lines
7.9 KiB
Python
215 lines
7.9 KiB
Python
"""Tests for OpenRouter sync tasks.
|
|
|
|
T56: Task to sync usage stats from OpenRouter.
|
|
T57: Task to validate API keys.
|
|
"""
|
|
import pytest
|
|
from datetime import datetime, date, timedelta
|
|
from decimal import Decimal
|
|
from unittest.mock import Mock, patch, MagicMock, AsyncMock
|
|
import httpx
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSyncUsageStats:
|
|
"""Test suite for sync_usage_stats task."""
|
|
|
|
def test_sync_usage_stats_has_correct_decorator(self):
|
|
"""Test that sync_usage_stats has correct scheduled_job decorator."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import sync_usage_stats
|
|
from openrouter_monitor.tasks.scheduler import get_scheduler
|
|
|
|
# Act
|
|
scheduler = get_scheduler()
|
|
job = scheduler.get_job('sync_usage_stats')
|
|
|
|
# Assert
|
|
assert job is not None
|
|
assert job.func == sync_usage_stats
|
|
assert isinstance(job.trigger, IntervalTrigger)
|
|
assert job.trigger.interval.total_seconds() == 3600 # 1 hour
|
|
|
|
def test_sync_usage_stats_is_async_function(self):
|
|
"""Test that sync_usage_stats is an async function."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import sync_usage_stats
|
|
import inspect
|
|
|
|
# Assert
|
|
assert inspect.iscoroutinefunction(sync_usage_stats)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_usage_stats_handles_empty_keys(self):
|
|
"""Test that sync completes gracefully with no active keys."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import sync_usage_stats
|
|
|
|
# Create mock result with empty keys
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = []
|
|
|
|
async def mock_execute(*args, **kwargs):
|
|
return mock_result
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.execute = mock_execute
|
|
mock_db.commit = AsyncMock()
|
|
|
|
with patch('openrouter_monitor.tasks.sync.SessionLocal') as mock_session:
|
|
mock_session.return_value.__enter__ = Mock(return_value=mock_db)
|
|
mock_session.return_value.__exit__ = Mock(return_value=False)
|
|
|
|
# Act & Assert - should complete without error
|
|
await sync_usage_stats()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_usage_stats_handles_decryption_error(self):
|
|
"""Test that sync handles decryption errors gracefully."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import sync_usage_stats
|
|
|
|
mock_key = MagicMock()
|
|
mock_key.id = 1
|
|
mock_key.key_encrypted = "encrypted"
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [mock_key]
|
|
|
|
async def mock_execute(*args, **kwargs):
|
|
return mock_result
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.execute = mock_execute
|
|
mock_db.commit = AsyncMock()
|
|
|
|
with patch('openrouter_monitor.tasks.sync.SessionLocal') as mock_session, \
|
|
patch('openrouter_monitor.tasks.sync.EncryptionService') as mock_encrypt:
|
|
|
|
mock_session.return_value.__enter__ = Mock(return_value=mock_db)
|
|
mock_session.return_value.__exit__ = Mock(return_value=False)
|
|
|
|
# Simulate decryption error
|
|
mock_encrypt_instance = MagicMock()
|
|
mock_encrypt_instance.decrypt.side_effect = Exception("Decryption failed")
|
|
mock_encrypt.return_value = mock_encrypt_instance
|
|
|
|
# Act & Assert - should not raise
|
|
await sync_usage_stats()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestValidateApiKeys:
|
|
"""Test suite for validate_api_keys task (T57)."""
|
|
|
|
def test_validate_api_keys_has_correct_decorator(self):
|
|
"""Test that validate_api_keys has correct scheduled_job decorator."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import validate_api_keys
|
|
from openrouter_monitor.tasks.scheduler import get_scheduler
|
|
|
|
# Act
|
|
scheduler = get_scheduler()
|
|
job = scheduler.get_job('validate_api_keys')
|
|
|
|
# Assert
|
|
assert job is not None
|
|
assert job.func == validate_api_keys
|
|
assert isinstance(job.trigger, CronTrigger)
|
|
# Should be a daily cron trigger at specific hour
|
|
|
|
def test_validate_api_keys_is_async_function(self):
|
|
"""Test that validate_api_keys is an async function."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import validate_api_keys
|
|
import inspect
|
|
|
|
# Assert
|
|
assert inspect.iscoroutinefunction(validate_api_keys)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_api_keys_handles_empty_keys(self):
|
|
"""Test that validation completes gracefully with no active keys."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import validate_api_keys
|
|
|
|
# Create mock result with empty keys
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = []
|
|
|
|
async def mock_execute(*args, **kwargs):
|
|
return mock_result
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.execute = mock_execute
|
|
mock_db.commit = AsyncMock()
|
|
|
|
with patch('openrouter_monitor.tasks.sync.SessionLocal') as mock_session:
|
|
mock_session.return_value.__enter__ = Mock(return_value=mock_db)
|
|
mock_session.return_value.__exit__ = Mock(return_value=False)
|
|
|
|
# Act & Assert - should complete without error
|
|
await validate_api_keys()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_api_keys_handles_decryption_error(self):
|
|
"""Test that validation handles decryption errors gracefully."""
|
|
# Arrange
|
|
from openrouter_monitor.tasks.sync import validate_api_keys
|
|
|
|
mock_key = MagicMock()
|
|
mock_key.id = 1
|
|
mock_key.key_encrypted = "encrypted"
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [mock_key]
|
|
|
|
async def mock_execute(*args, **kwargs):
|
|
return mock_result
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.execute = mock_execute
|
|
mock_db.commit = AsyncMock()
|
|
|
|
with patch('openrouter_monitor.tasks.sync.SessionLocal') as mock_session, \
|
|
patch('openrouter_monitor.tasks.sync.EncryptionService') as mock_encrypt:
|
|
|
|
mock_session.return_value.__enter__ = Mock(return_value=mock_db)
|
|
mock_session.return_value.__exit__ = Mock(return_value=False)
|
|
|
|
# Simulate decryption error
|
|
mock_encrypt_instance = MagicMock()
|
|
mock_encrypt_instance.decrypt.side_effect = Exception("Decryption failed")
|
|
mock_encrypt.return_value = mock_encrypt_instance
|
|
|
|
# Act & Assert - should not raise
|
|
await validate_api_keys()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSyncConstants:
|
|
"""Test suite for sync module constants."""
|
|
|
|
def test_openrouter_urls_defined(self):
|
|
"""Test that OpenRouter URLs are defined."""
|
|
from openrouter_monitor.tasks.sync import (
|
|
OPENROUTER_USAGE_URL,
|
|
OPENROUTER_AUTH_URL,
|
|
RATE_LIMIT_DELAY
|
|
)
|
|
|
|
assert 'openrouter.ai' in OPENROUTER_USAGE_URL
|
|
assert 'openrouter.ai' in OPENROUTER_AUTH_URL
|
|
assert RATE_LIMIT_DELAY == 0.35
|
|
|
|
def test_rate_limit_delay_respects_openrouter_limits(self):
|
|
"""Test that rate limit delay respects OpenRouter 20 req/min limit."""
|
|
from openrouter_monitor.tasks.sync import RATE_LIMIT_DELAY
|
|
|
|
# 20 requests per minute = 3 seconds per request
|
|
# We use 0.35s to be safe (allows ~171 req/min, well under limit)
|
|
assert RATE_LIMIT_DELAY >= 0.3 # At least 0.3s
|
|
assert RATE_LIMIT_DELAY <= 1.0 # But not too slow
|