Files
openrouter-watcher/tests/unit/tasks/test_sync.py
Luca Sacchi Ricciardi 3ae5d736ce 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
2026-04-07 17:41:24 +02:00

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