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:
0
tests/unit/tasks/__init__.py
Normal file
0
tests/unit/tasks/__init__.py
Normal file
107
tests/unit/tasks/test_cleanup.py
Normal file
107
tests/unit/tasks/test_cleanup.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Tests for cleanup tasks.
|
||||
|
||||
T58: Task to clean up old usage stats data.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, date, timedelta
|
||||
from unittest.mock import Mock, patch, MagicMock, AsyncMock
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCleanupOldUsageStats:
|
||||
"""Test suite for cleanup_old_usage_stats task."""
|
||||
|
||||
def test_cleanup_has_correct_decorator(self):
|
||||
"""Test that cleanup_old_usage_stats has correct scheduled_job decorator."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.cleanup import cleanup_old_usage_stats
|
||||
from openrouter_monitor.tasks.scheduler import get_scheduler
|
||||
|
||||
# Act
|
||||
scheduler = get_scheduler()
|
||||
job = scheduler.get_job('cleanup_old_usage_stats')
|
||||
|
||||
# Assert
|
||||
assert job is not None
|
||||
assert job.func == cleanup_old_usage_stats
|
||||
assert isinstance(job.trigger, CronTrigger)
|
||||
|
||||
def test_cleanup_is_async_function(self):
|
||||
"""Test that cleanup_old_usage_stats is an async function."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.cleanup import cleanup_old_usage_stats
|
||||
import inspect
|
||||
|
||||
# Assert
|
||||
assert inspect.iscoroutinefunction(cleanup_old_usage_stats)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_handles_errors_gracefully(self):
|
||||
"""Test that cleanup handles errors without crashing."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.cleanup import cleanup_old_usage_stats
|
||||
|
||||
with patch('openrouter_monitor.tasks.cleanup.SessionLocal') as mock_session:
|
||||
# Simulate database error
|
||||
mock_session.side_effect = Exception("Database connection failed")
|
||||
|
||||
# Act & Assert - should not raise
|
||||
await cleanup_old_usage_stats()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_uses_retention_days_from_config(self):
|
||||
"""Test that cleanup uses retention days from settings."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.cleanup import cleanup_old_usage_stats
|
||||
from openrouter_monitor.config import get_settings
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.rowcount = 0
|
||||
|
||||
async def mock_execute(*args, **kwargs):
|
||||
return mock_result
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.execute = mock_execute
|
||||
mock_db.commit = Mock()
|
||||
|
||||
# Get actual retention days from config
|
||||
settings = get_settings()
|
||||
expected_retention = settings.usage_stats_retention_days
|
||||
|
||||
with patch('openrouter_monitor.tasks.cleanup.SessionLocal') as mock_session:
|
||||
mock_session.return_value.__enter__ = Mock(return_value=mock_db)
|
||||
mock_session.return_value.__exit__ = Mock(return_value=False)
|
||||
|
||||
# Act
|
||||
await cleanup_old_usage_stats()
|
||||
|
||||
# Assert - verify retention days is reasonable (default 365)
|
||||
assert expected_retention > 0
|
||||
assert expected_retention <= 365 * 5 # Max 5 years
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCleanupConfiguration:
|
||||
"""Test suite for cleanup configuration."""
|
||||
|
||||
def test_retention_days_configurable(self):
|
||||
"""Test that retention days is configurable."""
|
||||
from openrouter_monitor.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Should have a default value
|
||||
assert hasattr(settings, 'usage_stats_retention_days')
|
||||
assert isinstance(settings.usage_stats_retention_days, int)
|
||||
assert settings.usage_stats_retention_days > 0
|
||||
|
||||
def test_default_retention_is_one_year(self):
|
||||
"""Test that default retention period is approximately one year."""
|
||||
from openrouter_monitor.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Default should be 365 days (1 year)
|
||||
assert settings.usage_stats_retention_days == 365
|
||||
194
tests/unit/tasks/test_scheduler.py
Normal file
194
tests/unit/tasks/test_scheduler.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Tests for APScheduler task scheduler.
|
||||
|
||||
T55: Unit tests for the task scheduler implementation.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestScheduler:
|
||||
"""Test suite for scheduler singleton and decorator."""
|
||||
|
||||
def test_get_scheduler_returns_singleton(self):
|
||||
"""Test that get_scheduler returns the same instance."""
|
||||
# Arrange & Act
|
||||
from openrouter_monitor.tasks.scheduler import get_scheduler, _scheduler
|
||||
|
||||
# First call should create scheduler
|
||||
scheduler1 = get_scheduler()
|
||||
scheduler2 = get_scheduler()
|
||||
|
||||
# Assert
|
||||
assert scheduler1 is scheduler2
|
||||
assert isinstance(scheduler1, AsyncIOScheduler)
|
||||
assert scheduler1.timezone.zone == 'UTC'
|
||||
|
||||
def test_get_scheduler_creates_new_if_none(self):
|
||||
"""Test that get_scheduler creates scheduler if None."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks import scheduler as scheduler_module
|
||||
|
||||
# Reset singleton
|
||||
original_scheduler = scheduler_module._scheduler
|
||||
scheduler_module._scheduler = None
|
||||
|
||||
try:
|
||||
# Act
|
||||
scheduler = scheduler_module.get_scheduler()
|
||||
|
||||
# Assert
|
||||
assert scheduler is not None
|
||||
assert isinstance(scheduler, AsyncIOScheduler)
|
||||
finally:
|
||||
# Restore
|
||||
scheduler_module._scheduler = original_scheduler
|
||||
|
||||
def test_scheduled_job_decorator_registers_job(self):
|
||||
"""Test that @scheduled_job decorator registers a job."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.scheduler import get_scheduler, scheduled_job
|
||||
|
||||
scheduler = get_scheduler()
|
||||
initial_job_count = len(scheduler.get_jobs())
|
||||
|
||||
# Act
|
||||
@scheduled_job(IntervalTrigger(hours=1), id='test_job')
|
||||
async def test_task():
|
||||
"""Test task."""
|
||||
pass
|
||||
|
||||
# Assert
|
||||
jobs = scheduler.get_jobs()
|
||||
assert len(jobs) == initial_job_count + 1
|
||||
|
||||
# Find our job
|
||||
job = scheduler.get_job('test_job')
|
||||
assert job is not None
|
||||
assert job.func == test_task
|
||||
|
||||
def test_scheduled_job_with_cron_trigger(self):
|
||||
"""Test @scheduled_job with CronTrigger."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.scheduler import get_scheduler, scheduled_job
|
||||
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Act
|
||||
@scheduled_job(CronTrigger(hour=2, minute=0), id='daily_job')
|
||||
async def daily_task():
|
||||
"""Daily task."""
|
||||
pass
|
||||
|
||||
# Assert
|
||||
job = scheduler.get_job('daily_job')
|
||||
assert job is not None
|
||||
assert isinstance(job.trigger, CronTrigger)
|
||||
|
||||
def test_init_scheduler_starts_scheduler(self):
|
||||
"""Test that init_scheduler starts the scheduler."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.scheduler import init_scheduler, get_scheduler
|
||||
|
||||
scheduler = get_scheduler()
|
||||
|
||||
with patch.object(scheduler, 'start') as mock_start:
|
||||
# Act
|
||||
init_scheduler()
|
||||
|
||||
# Assert
|
||||
mock_start.assert_called_once()
|
||||
|
||||
def test_shutdown_scheduler_stops_scheduler(self):
|
||||
"""Test that shutdown_scheduler stops the scheduler."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.scheduler import shutdown_scheduler, get_scheduler
|
||||
|
||||
scheduler = get_scheduler()
|
||||
|
||||
with patch.object(scheduler, 'shutdown') as mock_shutdown:
|
||||
# Act
|
||||
shutdown_scheduler()
|
||||
|
||||
# Assert
|
||||
mock_shutdown.assert_called_once_with(wait=True)
|
||||
|
||||
def test_scheduler_timezone_is_utc(self):
|
||||
"""Test that scheduler uses UTC timezone."""
|
||||
# Arrange & Act
|
||||
from openrouter_monitor.tasks.scheduler import get_scheduler
|
||||
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Assert
|
||||
assert scheduler.timezone.zone == 'UTC'
|
||||
|
||||
def test_scheduled_job_preserves_function(self):
|
||||
"""Test that decorator preserves original function."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.scheduler import scheduled_job
|
||||
|
||||
# Act
|
||||
@scheduled_job(IntervalTrigger(minutes=5), id='preserve_test')
|
||||
async def my_task():
|
||||
"""My task docstring."""
|
||||
return "result"
|
||||
|
||||
# Assert - function should be returned unchanged
|
||||
assert my_task.__name__ == 'my_task'
|
||||
assert my_task.__doc__ == 'My task docstring.'
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSchedulerIntegration:
|
||||
"""Integration tests for scheduler lifecycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_start_stop_cycle(self):
|
||||
"""Test complete scheduler start/stop cycle."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.scheduler import get_scheduler
|
||||
import asyncio
|
||||
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Act & Assert - should not raise
|
||||
scheduler.start()
|
||||
assert scheduler.running
|
||||
|
||||
scheduler.shutdown(wait=True)
|
||||
# Give async loop time to process shutdown
|
||||
await asyncio.sleep(0.1)
|
||||
# Note: scheduler.running might still be True in async tests
|
||||
# due to event loop differences, but shutdown should not raise
|
||||
|
||||
def test_multiple_jobs_can_be_registered(self):
|
||||
"""Test that multiple jobs can be registered."""
|
||||
# Arrange
|
||||
from openrouter_monitor.tasks.scheduler import get_scheduler, scheduled_job
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Act
|
||||
@scheduled_job(IntervalTrigger(hours=1), id='job1')
|
||||
async def job1():
|
||||
pass
|
||||
|
||||
@scheduled_job(IntervalTrigger(hours=2), id='job2')
|
||||
async def job2():
|
||||
pass
|
||||
|
||||
@scheduled_job(CronTrigger(day_of_week='sun', hour=3), id='job3')
|
||||
async def job3():
|
||||
pass
|
||||
|
||||
# Assert
|
||||
jobs = scheduler.get_jobs()
|
||||
job_ids = [job.id for job in jobs]
|
||||
assert 'job1' in job_ids
|
||||
assert 'job2' in job_ids
|
||||
assert 'job3' in job_ids
|
||||
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