Files
Aniworld/tests/api/test_scheduler_endpoints.py
Lukas 0265ae2a70 feat: cron-based scheduler with auto-download after rescan
- Replace asyncio sleep loop with APScheduler AsyncIOScheduler + CronTrigger
- Add schedule_time (HH:MM), schedule_days (days of week), auto_download_after_rescan fields to SchedulerConfig
- Add _auto_download_missing() to queue missing episodes after rescan
- Reload config live via reload_config(SchedulerConfig) without restart
- Update GET/POST /api/scheduler/config to return {success, config, status} envelope
- Add day-of-week pill toggles to Settings -> Scheduler section in UI
- Update JS loadSchedulerConfig / saveSchedulerConfig for new API shape
- Add 29 unit tests for SchedulerConfig model, 18 unit tests for SchedulerService
- Rewrite 23 endpoint tests and 36 integration tests for APScheduler behaviour
- Coverage: 96% api/scheduler, 95% scheduler_service, 90% total (>= 80% threshold)
- Update docs: API.md, CONFIGURATION.md, features.md, CHANGELOG.md
2026-02-21 08:56:17 +01:00

445 lines
19 KiB
Python

"""Tests for scheduler API endpoints.
This module tests all scheduler management REST API endpoints including
configuration retrieval, updates, and manual rescan triggering.
"""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.models.config import SchedulerConfig
from src.server.services.auth_service import auth_service
@pytest.fixture(autouse=True)
def reset_auth_state():
"""Reset auth service state before each test."""
auth_service._hash = None
auth_service._failed.clear()
if not auth_service.is_configured():
auth_service.setup_master_password("TestPass123!")
yield
auth_service._hash = None
auth_service._failed.clear()
@pytest.fixture
async def client():
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def authenticated_client(client):
"""Create an authenticated test client with token."""
response = await client.post(
"/api/auth/login",
json={"password": "TestPass123!"}
)
assert response.status_code == 200
token = response.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_config_service():
"""Create mock configuration service with default SchedulerConfig."""
service = Mock()
config = Mock()
config.scheduler = SchedulerConfig(
enabled=True,
interval_minutes=60,
schedule_time="03:00",
schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
auto_download_after_rescan=False,
)
def save_config_side_effect(new_config):
config.scheduler = new_config.scheduler
service.load_config = Mock(return_value=config)
service.save_config = Mock(side_effect=save_config_side_effect)
return service
@pytest.fixture
def mock_scheduler_service():
"""Create a mock scheduler service returning a basic status."""
svc = Mock()
svc.get_status = Mock(return_value={
"is_running": True,
"next_run": None,
"last_run": None,
"scan_in_progress": False,
})
svc.reload_config = Mock()
return svc
# ---------------------------------------------------------------------------
# GET /api/scheduler/config
# ---------------------------------------------------------------------------
class TestGetSchedulerConfig:
"""Tests for GET /api/scheduler/config endpoint."""
@pytest.mark.asyncio
async def test_returns_success_envelope(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Response carries the top-level success/config/status envelope."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "config" in data
assert "status" in data
@pytest.mark.asyncio
async def test_config_contains_all_fields(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Config block includes all SchedulerConfig fields."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
cfg = response.json()["config"]
assert cfg["enabled"] is True
assert cfg["interval_minutes"] == 60
assert cfg["schedule_time"] == "03:00"
assert cfg["schedule_days"] == ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
assert cfg["auto_download_after_rescan"] is False
@pytest.mark.asyncio
async def test_status_block_present(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Status block includes runtime keys."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
st = response.json()["status"]
for key in ("is_running", "next_run", "last_run", "scan_in_progress"):
assert key in st
@pytest.mark.asyncio
async def test_unauthorized(self, client):
"""GET without auth token returns 401."""
response = await client.get("/api/scheduler/config")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_config_load_failure_returns_500(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""500 when config_service.load_config raises ConfigServiceError."""
from src.server.services.config_service import ConfigServiceError
mock_config_service.load_config.side_effect = ConfigServiceError("disk error")
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
assert response.status_code == 500
assert "Failed to load scheduler configuration" in response.text
# ---------------------------------------------------------------------------
# POST /api/scheduler/config
# ---------------------------------------------------------------------------
class TestUpdateSchedulerConfig:
"""Tests for POST /api/scheduler/config endpoint."""
@pytest.mark.asyncio
async def test_update_returns_success_envelope(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""POST returns success envelope with saved values."""
payload = {
"enabled": False,
"interval_minutes": 120,
"schedule_time": "06:30",
"schedule_days": ["mon", "wed", "fri"],
"auto_download_after_rescan": True,
}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["config"]["enabled"] is False
assert data["config"]["schedule_time"] == "06:30"
assert data["config"]["schedule_days"] == ["mon", "wed", "fri"]
assert data["config"]["auto_download_after_rescan"] is True
@pytest.mark.asyncio
async def test_update_persists_to_config_service(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""POST calls save_config exactly once."""
payload = {"enabled": True, "interval_minutes": 30}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
mock_config_service.save_config.assert_called_once()
@pytest.mark.asyncio
async def test_reload_config_called_after_save(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""POST calls scheduler_service.reload_config(SchedulerConfig) after save."""
payload = {"enabled": True, "schedule_time": "10:00"}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
await authenticated_client.post("/api/scheduler/config", json=payload)
mock_scheduler_service.reload_config.assert_called_once()
call_arg = mock_scheduler_service.reload_config.call_args[0][0]
assert isinstance(call_arg, SchedulerConfig)
assert call_arg.schedule_time == "10:00"
@pytest.mark.asyncio
async def test_update_unauthorized(self, client):
"""POST without auth token returns 401."""
response = await client.post(
"/api/scheduler/config",
json={"enabled": False, "interval_minutes": 120},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_invalid_interval_returns_422(self, authenticated_client):
"""interval_minutes < 1 triggers Pydantic validation error (422)."""
response = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "interval_minutes": 0},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_invalid_schedule_time_returns_422(self, authenticated_client):
"""Bad schedule_time format triggers validation error (422)."""
response = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "schedule_time": "25:00"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_invalid_schedule_days_returns_422(self, authenticated_client):
"""Unknown day abbreviation triggers validation error (422)."""
response = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "schedule_days": ["monday"]},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_empty_schedule_days_accepted(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Empty schedule_days list is valid (disables the cron job)."""
payload = {"enabled": True, "schedule_days": []}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
assert response.json()["config"]["schedule_days"] == []
@pytest.mark.asyncio
async def test_update_enable_disable_toggle(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Toggling enabled is reflected in the returned config."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
r1 = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "interval_minutes": 60},
)
assert r1.json()["config"]["enabled"] is True
r2 = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": False, "interval_minutes": 60},
)
assert r2.json()["config"]["enabled"] is False
@pytest.mark.asyncio
async def test_save_failure_returns_500(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""500 when config_service.save_config raises ConfigServiceError."""
from src.server.services.config_service import ConfigServiceError
mock_config_service.save_config.side_effect = ConfigServiceError("disk full")
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": False},
)
assert response.status_code == 500
assert "Failed to update scheduler configuration" in response.text
@pytest.mark.asyncio
async def test_backward_compat_minimal_payload(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Payload with only legacy fields fills new fields with model defaults."""
payload = {"enabled": True, "interval_minutes": 60}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
cfg = response.json()["config"]
assert cfg["schedule_time"] == "03:00"
assert cfg["auto_download_after_rescan"] is False
assert len(cfg["schedule_days"]) == 7
@pytest.mark.asyncio
async def test_interval_boundary_values(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""interval_minutes = 1 and 10080 (1 week) are both valid."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
for minutes in (1, 10080):
r = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "interval_minutes": minutes},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# POST /api/scheduler/trigger-rescan
# ---------------------------------------------------------------------------
class TestTriggerRescan:
"""Tests for POST /api/scheduler/trigger-rescan endpoint."""
@pytest.mark.asyncio
async def test_trigger_rescan_success(self, authenticated_client):
"""Successful trigger returns 200 with a message."""
mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"})
mock_series_app = Mock()
with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
patch("src.server.api.anime.trigger_rescan", mock_trigger):
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 200
assert "message" in response.json()
mock_trigger.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_rescan_unauthorized(self, client):
"""Trigger without auth token returns 401."""
response = await client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_trigger_rescan_series_app_not_initialized(
self, authenticated_client
):
"""503 when SeriesApp is not yet initialised."""
with patch("src.server.utils.dependencies.get_series_app", return_value=None):
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 503
assert "SeriesApp not initialized" in response.text
@pytest.mark.asyncio
async def test_trigger_rescan_failure(self, authenticated_client):
"""500 when underlying rescan call raises an exception."""
mock_trigger = AsyncMock(side_effect=Exception("Rescan failed"))
mock_series_app = Mock()
with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
patch("src.server.api.anime.trigger_rescan", mock_trigger):
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 500
assert "Failed to trigger rescan" in response.text
# ---------------------------------------------------------------------------
# Multi-step integration tests
# ---------------------------------------------------------------------------
class TestSchedulerEndpointsIntegration:
"""Multi-step integration tests for scheduler endpoints."""
@pytest.mark.asyncio
async def test_full_config_workflow(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""GET → POST → verify save called and response consistent."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
r = await authenticated_client.get("/api/scheduler/config")
assert r.status_code == 200
assert r.json()["config"]["enabled"] is True
r = await authenticated_client.post(
"/api/scheduler/config",
json={
"enabled": False,
"interval_minutes": 30,
"schedule_time": "12:00",
"schedule_days": ["mon", "fri"],
},
)
assert r.status_code == 200
cfg = r.json()["config"]
assert cfg["enabled"] is False
assert cfg["interval_minutes"] == 30
assert cfg["schedule_time"] == "12:00"
assert cfg["schedule_days"] == ["mon", "fri"]
mock_config_service.save_config.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_rescan_after_config_update(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""POST config then POST trigger-rescan both succeed."""
mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"})
mock_series_app = Mock()
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service), \
patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
patch("src.server.api.anime.trigger_rescan", mock_trigger):
r = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "interval_minutes": 360},
)
assert r.status_code == 200
r = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert r.status_code == 200
mock_trigger.assert_called_once()