feat(tasks): T55-T58 implement background tasks for OpenRouter sync
- 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
This commit is contained in:
214
tests/unit/tasks/test_sync.py
Normal file
214
tests/unit/tasks/test_sync.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user