Files
openrouter-watcher/tests/unit/tasks/test_scheduler.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

195 lines
6.3 KiB
Python

"""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