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
This commit is contained in:
@@ -36,393 +36,409 @@ async def client():
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Create an authenticated test client with token."""
|
||||
# Login to get token
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPass123!"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Add token to default headers
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_service():
|
||||
"""Create mock configuration service."""
|
||||
"""Create mock configuration service with default SchedulerConfig."""
|
||||
service = Mock()
|
||||
|
||||
# Mock config object with scheduler section
|
||||
config = Mock()
|
||||
config.scheduler = SchedulerConfig(
|
||||
enabled=True,
|
||||
interval_minutes=60
|
||||
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):
|
||||
"""Update the scheduler config when save is called."""
|
||||
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_get_scheduler_config_success(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_config_service
|
||||
async def test_returns_success_envelope(
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""Test successful scheduler configuration retrieval."""
|
||||
with patch(
|
||||
'src.server.api.scheduler.get_config_service',
|
||||
return_value=mock_config_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["enabled"] is True
|
||||
assert data["interval_minutes"] == 60
|
||||
mock_config_service.load_config.assert_called_once()
|
||||
|
||||
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_get_scheduler_config_unauthorized(self, client):
|
||||
"""Test scheduler config retrieval without authentication."""
|
||||
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_get_scheduler_config_load_failure(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_config_service
|
||||
async def test_config_load_failure_returns_500(
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""Test scheduler config retrieval when config loading fails."""
|
||||
"""500 when config_service.load_config raises ConfigServiceError."""
|
||||
from src.server.services.config_service import ConfigServiceError
|
||||
|
||||
mock_config_service.load_config.side_effect = ConfigServiceError(
|
||||
"Failed to load config"
|
||||
)
|
||||
|
||||
with patch(
|
||||
'src.server.api.scheduler.get_config_service',
|
||||
return_value=mock_config_service
|
||||
):
|
||||
|
||||
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
|
||||
|
||||
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_scheduler_config_success(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_config_service
|
||||
async def test_update_returns_success_envelope(
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""Test successful scheduler configuration update."""
|
||||
new_config = {
|
||||
"""POST returns success envelope with saved values."""
|
||||
payload = {
|
||||
"enabled": False,
|
||||
"interval_minutes": 120
|
||||
"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
|
||||
):
|
||||
response = await authenticated_client.post(
|
||||
"/api/scheduler/config",
|
||||
json=new_config
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["enabled"] is False
|
||||
assert data["interval_minutes"] == 120
|
||||
|
||||
mock_config_service.load_config.assert_called_once()
|
||||
mock_config_service.save_config.assert_called_once()
|
||||
|
||||
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_scheduler_config_unauthorized(self, client):
|
||||
"""Test scheduler config update without authentication."""
|
||||
new_config = {
|
||||
"enabled": False,
|
||||
"interval_minutes": 120
|
||||
}
|
||||
|
||||
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=new_config
|
||||
json={"enabled": False, "interval_minutes": 120},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_scheduler_config_invalid_data(
|
||||
self,
|
||||
authenticated_client
|
||||
):
|
||||
"""Test scheduler config update with invalid data."""
|
||||
invalid_config = {
|
||||
"enabled": "not_a_boolean", # Should be boolean
|
||||
"interval_minutes": -1 # Should be positive (>= 1)
|
||||
}
|
||||
|
||||
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=invalid_config
|
||||
json={"enabled": True, "interval_minutes": 0},
|
||||
)
|
||||
# Pydantic validation should fail with 422
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_scheduler_config_save_failure(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_config_service
|
||||
):
|
||||
"""Test scheduler config update when save fails."""
|
||||
from src.server.services.config_service import ConfigServiceError
|
||||
|
||||
mock_config_service.save_config.side_effect = ConfigServiceError(
|
||||
"Failed to save config"
|
||||
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"},
|
||||
)
|
||||
|
||||
new_config = {
|
||||
"enabled": False,
|
||||
"rescan_interval_hours": 48,
|
||||
"rescan_on_startup": True
|
||||
}
|
||||
|
||||
with patch(
|
||||
'src.server.api.scheduler.get_config_service',
|
||||
return_value=mock_config_service
|
||||
):
|
||||
response = await authenticated_client.post(
|
||||
"/api/scheduler/config",
|
||||
json=new_config
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "Failed to update scheduler configuration" in response.text
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_scheduler_enable_disable_toggle(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_config_service
|
||||
):
|
||||
"""Test toggling scheduler enabled state."""
|
||||
# First enable
|
||||
with patch(
|
||||
'src.server.api.scheduler.get_config_service',
|
||||
return_value=mock_config_service
|
||||
):
|
||||
response = await authenticated_client.post(
|
||||
"/api/scheduler/config",
|
||||
json={
|
||||
"enabled": True,
|
||||
"interval_minutes": 60
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is True
|
||||
|
||||
# Then disable
|
||||
response = await authenticated_client.post(
|
||||
"/api/scheduler/config",
|
||||
json={
|
||||
"enabled": False,
|
||||
"interval_minutes": 60
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
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_update_scheduler_interval_validation(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_config_service
|
||||
async def test_empty_schedule_days_accepted(
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""Test scheduler interval value validation."""
|
||||
with patch(
|
||||
'src.server.api.scheduler.get_config_service',
|
||||
return_value=mock_config_service
|
||||
):
|
||||
# Test minimum interval (1 minute)
|
||||
"""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": True,
|
||||
"interval_minutes": 1
|
||||
}
|
||||
json={"enabled": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test large interval (7 days = 10080 minutes)
|
||||
response = await authenticated_client.post(
|
||||
"/api/scheduler/config",
|
||||
json={
|
||||
"enabled": True,
|
||||
"interval_minutes": 10080
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
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):
|
||||
"""Test successful manual rescan trigger."""
|
||||
"""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
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
mock_trigger.assert_called_once()
|
||||
|
||||
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):
|
||||
"""Test manual rescan trigger without authentication."""
|
||||
"""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
|
||||
self, authenticated_client
|
||||
):
|
||||
"""Test manual rescan trigger when SeriesApp not initialized."""
|
||||
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
|
||||
"""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):
|
||||
"""Test manual rescan trigger when rescan fails."""
|
||||
mock_trigger = AsyncMock(
|
||||
side_effect=Exception("Rescan failed")
|
||||
)
|
||||
"""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
|
||||
|
||||
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:
|
||||
"""Integration tests for scheduler endpoints."""
|
||||
"""Multi-step integration tests for scheduler endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_config_workflow(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_config_service
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""Test complete workflow: get config, update, get again."""
|
||||
with patch(
|
||||
'src.server.api.scheduler.get_config_service',
|
||||
return_value=mock_config_service
|
||||
):
|
||||
# Get initial config
|
||||
response = await authenticated_client.get("/api/scheduler/config")
|
||||
assert response.status_code == 200
|
||||
initial_config = response.json()
|
||||
assert initial_config["enabled"] is True
|
||||
|
||||
# Update config
|
||||
new_config = {
|
||||
"enabled": False,
|
||||
"interval_minutes": 30
|
||||
}
|
||||
response = await authenticated_client.post(
|
||||
"""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=new_config
|
||||
json={
|
||||
"enabled": False,
|
||||
"interval_minutes": 30,
|
||||
"schedule_time": "12:00",
|
||||
"schedule_days": ["mon", "fri"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
updated_config = response.json()
|
||||
assert updated_config["enabled"] is False
|
||||
assert updated_config["interval_minutes"] == 30
|
||||
|
||||
# Verify config persisted
|
||||
mock_config_service.save_config.assert_called_once()
|
||||
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
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""Test triggering rescan after updating config."""
|
||||
"""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.utils.dependencies.get_series_app',
|
||||
return_value=mock_series_app
|
||||
), patch(
|
||||
'src.server.api.anime.trigger_rescan',
|
||||
mock_trigger
|
||||
):
|
||||
# Update config to enable scheduler
|
||||
response = await authenticated_client.post(
|
||||
|
||||
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
|
||||
}
|
||||
json={"enabled": True, "interval_minutes": 360},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Trigger manual rescan
|
||||
response = await authenticated_client.post(
|
||||
"/api/scheduler/trigger-rescan"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert r.status_code == 200
|
||||
|
||||
r = await authenticated_client.post("/api/scheduler/trigger-rescan")
|
||||
assert r.status_code == 200
|
||||
mock_trigger.assert_called_once()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Integration tests for scheduler workflow.
|
||||
|
||||
This module tests end-to-end scheduler workflows including:
|
||||
- Scheduler trigger → library rescan → database update workflow
|
||||
- Configuration changes apply immediately
|
||||
- Scheduler persistence after application restart
|
||||
- Concurrent manual and automated scan handling
|
||||
Tests end-to-end scheduler workflows with the APScheduler-based
|
||||
SchedulerService, covering lifecycle, manual triggers, config reloading,
|
||||
WebSocket broadcasting, auto-download, and concurrency protection.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
@@ -15,499 +13,511 @@ import pytest
|
||||
from src.server.models.config import AppConfig, SchedulerConfig
|
||||
from src.server.services.scheduler_service import (
|
||||
SchedulerService,
|
||||
SchedulerServiceError,
|
||||
_JOB_ID,
|
||||
get_scheduler_service,
|
||||
reset_scheduler_service,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_service():
|
||||
"""Create a mock configuration service."""
|
||||
"""Patch get_config_service used by SchedulerService.start()."""
|
||||
with patch("src.server.services.scheduler_service.get_config_service") as mock:
|
||||
config_service = Mock()
|
||||
|
||||
# Default configuration
|
||||
app_config = AppConfig(
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=True,
|
||||
interval_minutes=1 # Short interval for testing
|
||||
schedule_time="03:00",
|
||||
schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
||||
auto_download_after_rescan=False,
|
||||
)
|
||||
)
|
||||
config_service.load_config.return_value = app_config
|
||||
config_service.update_config = Mock()
|
||||
|
||||
mock.return_value = config_service
|
||||
yield config_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service():
|
||||
"""Create a mock anime service that simulates database updates."""
|
||||
"""Patch get_anime_service used inside _perform_rescan."""
|
||||
with patch("src.server.utils.dependencies.get_anime_service") as mock:
|
||||
service = Mock()
|
||||
service.rescan = AsyncMock()
|
||||
service.series_list = []
|
||||
|
||||
# Simulate database update during rescan
|
||||
async def rescan_side_effect():
|
||||
# Simulate finding new series
|
||||
service.series_list = [
|
||||
{"key": "series1", "name": "New Series 1"},
|
||||
{"key": "series2", "name": "New Series 2"}
|
||||
]
|
||||
await asyncio.sleep(0.1) # Simulate work
|
||||
|
||||
service.rescan.side_effect = rescan_side_effect
|
||||
mock.return_value = service
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket_service():
|
||||
"""Create a mock WebSocket service that tracks broadcasts."""
|
||||
"""Patch get_websocket_service to capture broadcasts."""
|
||||
with patch("src.server.services.websocket_service.get_websocket_service") as mock:
|
||||
service = Mock()
|
||||
service.manager = Mock()
|
||||
service.broadcasts = [] # Track all broadcasts
|
||||
|
||||
service.broadcasts = []
|
||||
|
||||
async def broadcast_side_effect(message):
|
||||
service.broadcasts.append(message)
|
||||
|
||||
|
||||
service.manager.broadcast = AsyncMock(side_effect=broadcast_side_effect)
|
||||
mock.return_value = service
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def scheduler_service():
|
||||
"""Create a fresh scheduler service instance for each test."""
|
||||
async def scheduler_service(mock_config_service):
|
||||
"""Fresh SchedulerService instance; stopped automatically after each test."""
|
||||
reset_scheduler_service()
|
||||
service = SchedulerService()
|
||||
yield service
|
||||
# Cleanup
|
||||
if service._is_running:
|
||||
await service.stop()
|
||||
svc = SchedulerService()
|
||||
yield svc
|
||||
if svc._is_running:
|
||||
await svc.stop()
|
||||
|
||||
|
||||
class TestSchedulerWorkflow:
|
||||
"""Tests for end-to-end scheduler workflows."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSchedulerLifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchedulerLifecycle:
|
||||
"""Tests for SchedulerService start/stop lifecycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_rescan_updates_database(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
):
|
||||
"""Test that scheduled rescan updates the database with new series."""
|
||||
# Start scheduler
|
||||
await scheduler_service.start()
|
||||
|
||||
# Wait for at least one scan cycle (1 minute + buffer)
|
||||
await asyncio.sleep(65)
|
||||
|
||||
# Verify database was updated
|
||||
assert mock_anime_service.rescan.call_count >= 1
|
||||
assert len(mock_anime_service.series_list) == 2
|
||||
|
||||
# Verify WebSocket notifications were sent
|
||||
assert len(mock_websocket_service.broadcasts) >= 2
|
||||
|
||||
# Check for rescan events
|
||||
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
|
||||
assert "scheduled_rescan_started" in event_types
|
||||
assert "scheduled_rescan_completed" in event_types
|
||||
|
||||
# Cleanup
|
||||
await scheduler_service.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configuration_change_applies_immediately(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
):
|
||||
"""Test that configuration changes are applied immediately."""
|
||||
# Start with 1 minute interval
|
||||
await scheduler_service.start()
|
||||
original_interval = scheduler_service._config.interval_minutes
|
||||
assert original_interval == 1
|
||||
|
||||
# Change interval to 2 minutes
|
||||
new_config = AppConfig(
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=True,
|
||||
interval_minutes=2
|
||||
)
|
||||
)
|
||||
mock_config_service.load_config.return_value = new_config
|
||||
|
||||
# Reload configuration
|
||||
await scheduler_service.reload_config()
|
||||
|
||||
# Verify new interval is applied
|
||||
assert scheduler_service._config.interval_minutes == 2
|
||||
assert scheduler_service._is_running is True # Should still be running
|
||||
|
||||
# Cleanup
|
||||
await scheduler_service.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_scheduler_stops_execution(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
):
|
||||
"""Test that disabling scheduler stops future rescans."""
|
||||
# Start scheduler
|
||||
async def test_start_sets_is_running(self, scheduler_service):
|
||||
"""start() sets _is_running to True."""
|
||||
await scheduler_service.start()
|
||||
assert scheduler_service._is_running is True
|
||||
|
||||
# Wait for one scan to complete
|
||||
await asyncio.sleep(65)
|
||||
initial_scan_count = mock_anime_service.rescan.call_count
|
||||
assert initial_scan_count >= 1
|
||||
|
||||
# Disable scheduler
|
||||
disabled_config = AppConfig(
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=False,
|
||||
interval_minutes=1
|
||||
)
|
||||
)
|
||||
mock_config_service.load_config.return_value = disabled_config
|
||||
await scheduler_service.reload_config()
|
||||
|
||||
# Verify scheduler stopped
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_clears_is_running(self, scheduler_service):
|
||||
"""stop() sets _is_running to False."""
|
||||
await scheduler_service.start()
|
||||
await scheduler_service.stop()
|
||||
assert scheduler_service._is_running is False
|
||||
|
||||
# Wait another scan cycle
|
||||
await asyncio.sleep(65)
|
||||
|
||||
# Verify no additional scans occurred
|
||||
assert mock_anime_service.rescan.call_count == initial_scan_count
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_scan_blocks_scheduled_scan(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
):
|
||||
"""Test that manual scan prevents concurrent scheduled scan."""
|
||||
async def test_start_twice_raises(self, scheduler_service):
|
||||
"""Calling start() when already running raises SchedulerServiceError."""
|
||||
await scheduler_service.start()
|
||||
|
||||
# Make rescan slow to simulate long-running operation
|
||||
async def slow_rescan():
|
||||
await asyncio.sleep(2)
|
||||
|
||||
mock_anime_service.rescan.side_effect = slow_rescan
|
||||
|
||||
# Trigger manual scan
|
||||
task1 = asyncio.create_task(scheduler_service._perform_rescan())
|
||||
|
||||
# Wait a bit to ensure manual scan is in progress
|
||||
await asyncio.sleep(0.5)
|
||||
assert scheduler_service._scan_in_progress is True
|
||||
|
||||
# Try to trigger another scan (simulating scheduled trigger)
|
||||
result = await scheduler_service.trigger_rescan()
|
||||
|
||||
# Second scan should be blocked
|
||||
assert result is False
|
||||
|
||||
# Wait for first scan to complete
|
||||
await task1
|
||||
|
||||
# Verify only one scan executed
|
||||
assert mock_anime_service.rescan.call_count == 1
|
||||
|
||||
# Cleanup
|
||||
await scheduler_service.stop()
|
||||
with pytest.raises(SchedulerServiceError, match="already running"):
|
||||
await scheduler_service.start()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_state_persists_across_restart(
|
||||
self,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
):
|
||||
"""Test that scheduler can restart with same configuration."""
|
||||
# Create and start first scheduler instance
|
||||
async def test_stop_when_not_running_is_noop(self, scheduler_service):
|
||||
"""stop() when not started does not raise."""
|
||||
await scheduler_service.stop() # should not raise
|
||||
assert scheduler_service._is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_loads_config(self, scheduler_service, mock_config_service):
|
||||
"""start() loads configuration via config_service."""
|
||||
await scheduler_service.start()
|
||||
mock_config_service.load_config.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_disabled_scheduler_no_job(self, mock_config_service):
|
||||
"""Disabled scheduler starts but does not add an APScheduler job."""
|
||||
mock_config_service.load_config.return_value = AppConfig(
|
||||
scheduler=SchedulerConfig(enabled=False)
|
||||
)
|
||||
reset_scheduler_service()
|
||||
scheduler1 = SchedulerService()
|
||||
await scheduler1.start()
|
||||
|
||||
# Record configuration
|
||||
original_config = scheduler1._config
|
||||
assert scheduler1._is_running is True
|
||||
|
||||
# Stop scheduler (simulating app shutdown)
|
||||
await scheduler1.stop()
|
||||
assert scheduler1._is_running is False
|
||||
|
||||
# Create new scheduler instance (simulating app restart)
|
||||
reset_scheduler_service()
|
||||
scheduler2 = SchedulerService()
|
||||
|
||||
# Start new scheduler with same configuration
|
||||
await scheduler2.start()
|
||||
|
||||
# Verify it has same configuration and is running
|
||||
assert scheduler2._is_running is True
|
||||
assert scheduler2._config.enabled == original_config.enabled
|
||||
assert scheduler2._config.interval_minutes == original_config.interval_minutes
|
||||
|
||||
# Cleanup
|
||||
await scheduler2.stop()
|
||||
svc = SchedulerService()
|
||||
await svc.start()
|
||||
assert svc._is_running is True
|
||||
# No job should be registered
|
||||
assert svc._scheduler.get_job(_JOB_ID) is None
|
||||
await svc.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_recovers_from_rescan_failure(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
):
|
||||
"""Test that scheduler continues after rescan failure."""
|
||||
# Make first rescan fail, subsequent rescans succeed
|
||||
call_count = {"count": 0}
|
||||
|
||||
async def failing_rescan():
|
||||
call_count["count"] += 1
|
||||
if call_count["count"] == 1:
|
||||
raise Exception("Database connection error")
|
||||
# Subsequent calls succeed
|
||||
|
||||
mock_anime_service.rescan.side_effect = failing_rescan
|
||||
|
||||
async def test_start_registers_apscheduler_job(self, scheduler_service):
|
||||
"""Enabled scheduler registers a job with _JOB_ID."""
|
||||
await scheduler_service.start()
|
||||
job = scheduler_service._scheduler.get_job(_JOB_ID)
|
||||
assert job is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_after_stop(self, scheduler_service):
|
||||
"""Service can be started again after being stopped."""
|
||||
await scheduler_service.start()
|
||||
|
||||
# Wait for multiple scan cycles (2 minutes + buffer)
|
||||
await asyncio.sleep(130)
|
||||
|
||||
# Verify multiple scans were attempted despite failure
|
||||
assert mock_anime_service.rescan.call_count >= 2
|
||||
|
||||
# Verify error was broadcast
|
||||
error_broadcasts = [
|
||||
b for b in mock_websocket_service.broadcasts
|
||||
if b.get("type") == "scheduled_rescan_error"
|
||||
]
|
||||
assert len(error_broadcasts) >= 1
|
||||
|
||||
# Cleanup
|
||||
await scheduler_service.stop()
|
||||
await scheduler_service.start()
|
||||
assert scheduler_service._is_running is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSchedulerTriggerRescan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchedulerTriggerRescan:
|
||||
"""Tests for manual trigger_rescan workflow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_workflow_trigger_rescan_update_notify(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
async def test_trigger_rescan_calls_anime_service(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""Test complete workflow: trigger → rescan → update → notify."""
|
||||
"""trigger_rescan() calls anime_service.rescan()."""
|
||||
await scheduler_service.start()
|
||||
|
||||
# Trigger manual rescan
|
||||
result = await scheduler_service.trigger_rescan()
|
||||
assert result is True
|
||||
|
||||
# Verify workflow steps
|
||||
# 1. Rescan was performed
|
||||
assert mock_anime_service.rescan.call_count == 1
|
||||
|
||||
# 2. Database was updated with new series
|
||||
assert len(mock_anime_service.series_list) == 2
|
||||
|
||||
# 3. WebSocket notifications were sent
|
||||
assert len(mock_websocket_service.broadcasts) >= 2
|
||||
|
||||
# 4. Verify event sequence
|
||||
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
|
||||
start_index = event_types.index("scheduled_rescan_started")
|
||||
complete_index = event_types.index("scheduled_rescan_completed")
|
||||
assert complete_index > start_index # Complete comes after start
|
||||
|
||||
# 5. Verify scan time was recorded
|
||||
mock_anime_service.rescan.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_rescan_records_last_run(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""trigger_rescan() updates _last_scan_time."""
|
||||
await scheduler_service.start()
|
||||
await scheduler_service.trigger_rescan()
|
||||
assert scheduler_service._last_scan_time is not None
|
||||
assert isinstance(scheduler_service._last_scan_time, datetime)
|
||||
|
||||
# 6. Scan is no longer in progress
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_rescan_when_not_running_raises(self, scheduler_service):
|
||||
"""trigger_rescan() without start() raises SchedulerServiceError."""
|
||||
with pytest.raises(SchedulerServiceError, match="not running"):
|
||||
await scheduler_service.trigger_rescan()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_rescan_blocked_during_scan(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""Second trigger_rescan() returns False while a scan is in progress."""
|
||||
async def slow_rescan():
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
mock_anime_service.rescan.side_effect = slow_rescan
|
||||
await scheduler_service.start()
|
||||
|
||||
task = asyncio.create_task(scheduler_service._perform_rescan())
|
||||
await asyncio.sleep(0.05)
|
||||
assert scheduler_service._scan_in_progress is True
|
||||
|
||||
result = await scheduler_service.trigger_rescan()
|
||||
assert result is False
|
||||
|
||||
await task
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_rescan_scan_in_progress_false_after_completion(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""scan_in_progress returns to False after trigger_rescan completes."""
|
||||
await scheduler_service.start()
|
||||
await scheduler_service.trigger_rescan()
|
||||
assert scheduler_service._scan_in_progress is False
|
||||
|
||||
# Cleanup
|
||||
await scheduler_service.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_sequential_rescans(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""Test multiple sequential rescans execute successfully."""
|
||||
"""Three sequential manual rescans all execute successfully."""
|
||||
await scheduler_service.start()
|
||||
|
||||
# Trigger 3 manual rescans sequentially
|
||||
for i in range(3):
|
||||
for _ in range(3):
|
||||
result = await scheduler_service.trigger_rescan()
|
||||
assert result is True
|
||||
# Small delay between rescans
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify all 3 rescans executed
|
||||
assert mock_anime_service.rescan.call_count == 3
|
||||
|
||||
# Verify 6 WebSocket broadcasts (start + complete for each scan)
|
||||
assert len(mock_websocket_service.broadcasts) >= 6
|
||||
|
||||
# Cleanup
|
||||
await scheduler_service.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSchedulerWebSocketBroadcasts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchedulerWebSocketBroadcasts:
|
||||
"""Tests for WebSocket event emission during rescan."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_status_accuracy_during_workflow(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
async def test_rescan_broadcasts_started_event(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""Test that status accurately reflects scheduler state during workflow."""
|
||||
# Initial status
|
||||
"""_perform_rescan() broadcasts 'scheduled_rescan_started'."""
|
||||
await scheduler_service.start()
|
||||
await scheduler_service.trigger_rescan()
|
||||
|
||||
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
|
||||
assert "scheduled_rescan_started" in event_types
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_broadcasts_completed_event(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""_perform_rescan() broadcasts 'scheduled_rescan_completed'."""
|
||||
await scheduler_service.start()
|
||||
await scheduler_service.trigger_rescan()
|
||||
|
||||
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
|
||||
assert "scheduled_rescan_completed" in event_types
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_broadcasts_error_on_failure(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""_perform_rescan() broadcasts 'scheduled_rescan_error' when rescan raises."""
|
||||
mock_anime_service.rescan.side_effect = RuntimeError("DB failure")
|
||||
await scheduler_service.start()
|
||||
await scheduler_service._perform_rescan()
|
||||
|
||||
error_events = [
|
||||
b for b in mock_websocket_service.broadcasts
|
||||
if b["type"] == "scheduled_rescan_error"
|
||||
]
|
||||
assert len(error_events) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_completed_event_order(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""'started' event precedes 'completed' event in broadcast sequence."""
|
||||
await scheduler_service.start()
|
||||
await scheduler_service.trigger_rescan()
|
||||
|
||||
types = [b["type"] for b in mock_websocket_service.broadcasts]
|
||||
started_idx = types.index("scheduled_rescan_started")
|
||||
completed_idx = types.index("scheduled_rescan_completed")
|
||||
assert completed_idx > started_idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSchedulerGetStatus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchedulerGetStatus:
|
||||
"""Tests for get_status() accuracy."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_not_running_before_start(self, scheduler_service):
|
||||
"""is_running is False before start()."""
|
||||
status = scheduler_service.get_status()
|
||||
assert status["is_running"] is False
|
||||
assert status["scan_in_progress"] is False
|
||||
|
||||
# Start scheduler
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_is_running_after_start(self, scheduler_service):
|
||||
"""is_running is True after start()."""
|
||||
await scheduler_service.start()
|
||||
status = scheduler_service.get_status()
|
||||
assert status["is_running"] is True
|
||||
assert status["enabled"] is True
|
||||
assert status["interval_minutes"] == 1
|
||||
|
||||
# Make rescan slow to check in-progress status
|
||||
async def slow_rescan():
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
mock_anime_service.rescan.side_effect = slow_rescan
|
||||
|
||||
# Start rescan
|
||||
task = asyncio.create_task(scheduler_service._perform_rescan())
|
||||
|
||||
# Check status during rescan
|
||||
await asyncio.sleep(0.1)
|
||||
status = scheduler_service.get_status()
|
||||
assert status["scan_in_progress"] is True
|
||||
|
||||
# Wait for rescan to complete
|
||||
await task
|
||||
|
||||
# Check status after rescan
|
||||
status = scheduler_service.get_status()
|
||||
assert status["scan_in_progress"] is False
|
||||
assert status["last_scan_time"] is not None
|
||||
|
||||
# Cleanup
|
||||
await scheduler_service.stop()
|
||||
|
||||
# Final status
|
||||
status = scheduler_service.get_status()
|
||||
assert status["is_running"] is False
|
||||
|
||||
|
||||
class TestSchedulerEdgeCases:
|
||||
"""Tests for edge cases in scheduler workflows."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_enable_disable_cycles(
|
||||
self,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
async def test_status_last_run_populated_after_rescan(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""Test rapid enable/disable cycles don't cause issues."""
|
||||
reset_scheduler_service()
|
||||
scheduler = SchedulerService()
|
||||
|
||||
# Rapidly enable and disable 5 times
|
||||
for i in range(5):
|
||||
enabled_config = AppConfig(
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=True,
|
||||
interval_minutes=1
|
||||
)
|
||||
)
|
||||
disabled_config = AppConfig(
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=False,
|
||||
interval_minutes=1
|
||||
)
|
||||
)
|
||||
|
||||
if i % 2 == 0:
|
||||
mock_config_service.load_config.return_value = enabled_config
|
||||
await scheduler.reload_config()
|
||||
else:
|
||||
mock_config_service.load_config.return_value = disabled_config
|
||||
await scheduler.reload_config()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Final state should match last configuration (i=4 is even, so enabled)
|
||||
status = scheduler.get_status()
|
||||
assert status["is_running"] is True # Last config (i=4) was enabled
|
||||
|
||||
# Cleanup
|
||||
if scheduler._is_running:
|
||||
await scheduler.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interval_change_during_active_scan(
|
||||
self,
|
||||
scheduler_service,
|
||||
mock_config_service,
|
||||
mock_anime_service,
|
||||
mock_websocket_service
|
||||
):
|
||||
"""Test configuration change during active scan."""
|
||||
"""last_run is not None after a successful rescan."""
|
||||
await scheduler_service.start()
|
||||
|
||||
# Make rescan slow
|
||||
await scheduler_service.trigger_rescan()
|
||||
status = scheduler_service.get_status()
|
||||
assert status["last_run"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_scan_in_progress_during_slow_rescan(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""scan_in_progress is True while rescan is executing."""
|
||||
async def slow_rescan():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
mock_anime_service.rescan.side_effect = slow_rescan
|
||||
|
||||
# Start a rescan
|
||||
await scheduler_service.start()
|
||||
|
||||
task = asyncio.create_task(scheduler_service._perform_rescan())
|
||||
|
||||
# Change interval while scan is in progress
|
||||
await asyncio.sleep(0.2)
|
||||
new_config = AppConfig(
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=True,
|
||||
interval_minutes=5
|
||||
)
|
||||
)
|
||||
mock_config_service.load_config.return_value = new_config
|
||||
|
||||
# Reload config (should restart scheduler)
|
||||
await scheduler_service.reload_config()
|
||||
|
||||
# Wait for scan to complete
|
||||
await asyncio.sleep(0.05)
|
||||
assert scheduler_service.get_status()["scan_in_progress"] is True
|
||||
await task
|
||||
|
||||
# Verify new interval is applied
|
||||
assert scheduler_service._config.interval_minutes == 5
|
||||
|
||||
# Cleanup
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_is_running_false_after_stop(self, scheduler_service):
|
||||
"""is_running is False after stop()."""
|
||||
await scheduler_service.start()
|
||||
await scheduler_service.stop()
|
||||
assert scheduler_service.get_status()["is_running"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_includes_cron_fields(self, scheduler_service):
|
||||
"""get_status() includes schedule_time, schedule_days, auto_download keys."""
|
||||
await scheduler_service.start()
|
||||
status = scheduler_service.get_status()
|
||||
for key in ("schedule_time", "schedule_days", "auto_download_after_rescan", "next_run"):
|
||||
assert key in status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReloadConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReloadConfig:
|
||||
"""Tests for reload_config() live reconfiguration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_reschedules_job_on_time_change(self, scheduler_service):
|
||||
"""Changing schedule_time reschedules the existing job."""
|
||||
await scheduler_service.start()
|
||||
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
|
||||
|
||||
new_config = SchedulerConfig(enabled=True, schedule_time="08:00")
|
||||
scheduler_service.reload_config(new_config)
|
||||
|
||||
job = scheduler_service._scheduler.get_job(_JOB_ID)
|
||||
assert job is not None
|
||||
assert scheduler_service._config.schedule_time == "08:00"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_removes_job_when_disabled(self, scheduler_service):
|
||||
"""Setting enabled=False removes the APScheduler job."""
|
||||
await scheduler_service.start()
|
||||
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
|
||||
|
||||
scheduler_service.reload_config(
|
||||
SchedulerConfig(enabled=False)
|
||||
)
|
||||
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_removes_job_when_days_empty(self, scheduler_service):
|
||||
"""Empty schedule_days removes the APScheduler job."""
|
||||
await scheduler_service.start()
|
||||
scheduler_service.reload_config(
|
||||
SchedulerConfig(enabled=True, schedule_days=[])
|
||||
)
|
||||
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_adds_job_when_reenabling(self, scheduler_service):
|
||||
"""Re-enabling after disable adds a new job."""
|
||||
await scheduler_service.start()
|
||||
scheduler_service.reload_config(SchedulerConfig(enabled=False))
|
||||
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
|
||||
|
||||
scheduler_service.reload_config(
|
||||
SchedulerConfig(enabled=True, schedule_time="09:00")
|
||||
)
|
||||
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_updates_config_attribute(self, scheduler_service):
|
||||
"""reload_config() updates self._config with the supplied instance."""
|
||||
await scheduler_service.start()
|
||||
new = SchedulerConfig(enabled=True, schedule_time="14:30", schedule_days=["mon"])
|
||||
scheduler_service.reload_config(new)
|
||||
assert scheduler_service._config.schedule_time == "14:30"
|
||||
assert scheduler_service._config.schedule_days == ["mon"]
|
||||
|
||||
def test_reload_before_start_stores_config(self, scheduler_service):
|
||||
"""reload_config() before start() stores config without raising."""
|
||||
new = SchedulerConfig(enabled=True, schedule_time="22:00")
|
||||
scheduler_service.reload_config(new)
|
||||
assert scheduler_service._config.schedule_time == "22:00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAutoDownloadWorkflow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAutoDownloadWorkflow:
|
||||
"""Tests for auto-download-after-rescan integration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_download_triggered_when_enabled(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""_auto_download_missing() is called when auto_download_after_rescan=True."""
|
||||
scheduler_service._config = SchedulerConfig(
|
||||
enabled=True,
|
||||
auto_download_after_rescan=True,
|
||||
)
|
||||
scheduler_service._is_running = True
|
||||
|
||||
called = []
|
||||
|
||||
async def fake_auto_download():
|
||||
called.append(True)
|
||||
|
||||
scheduler_service._auto_download_missing = fake_auto_download
|
||||
await scheduler_service._perform_rescan()
|
||||
assert called == [True]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_download_not_called_when_disabled(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""_auto_download_missing() is NOT called when auto_download_after_rescan=False."""
|
||||
scheduler_service._config = SchedulerConfig(
|
||||
enabled=True,
|
||||
auto_download_after_rescan=False,
|
||||
)
|
||||
scheduler_service._is_running = True
|
||||
|
||||
called = []
|
||||
|
||||
async def fake_auto_download():
|
||||
called.append(True)
|
||||
|
||||
scheduler_service._auto_download_missing = fake_auto_download
|
||||
await scheduler_service._perform_rescan()
|
||||
assert called == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_download_error_broadcasts_event(
|
||||
self, scheduler_service, mock_anime_service, mock_websocket_service
|
||||
):
|
||||
"""Error in _auto_download_missing broadcasts 'auto_download_error'."""
|
||||
scheduler_service._config = SchedulerConfig(
|
||||
enabled=True,
|
||||
auto_download_after_rescan=True,
|
||||
)
|
||||
scheduler_service._is_running = True
|
||||
|
||||
async def failing_auto_download():
|
||||
raise RuntimeError("download failed")
|
||||
|
||||
scheduler_service._auto_download_missing = failing_auto_download
|
||||
await scheduler_service._perform_rescan()
|
||||
|
||||
error_events = [
|
||||
b for b in mock_websocket_service.broadcasts
|
||||
if b["type"] == "auto_download_error"
|
||||
]
|
||||
assert len(error_events) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSchedulerSingletonHelpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchedulerSingletonHelpers:
|
||||
"""Tests for module-level singleton helpers."""
|
||||
|
||||
def test_get_scheduler_service_returns_same_instance(self):
|
||||
"""get_scheduler_service() returns the same object on repeated calls."""
|
||||
svc1 = get_scheduler_service()
|
||||
svc2 = get_scheduler_service()
|
||||
assert svc1 is svc2
|
||||
|
||||
def test_reset_clears_singleton(self):
|
||||
"""reset_scheduler_service() causes get_scheduler_service() to return a new instance."""
|
||||
svc1 = get_scheduler_service()
|
||||
reset_scheduler_service()
|
||||
svc2 = get_scheduler_service()
|
||||
assert svc1 is not svc2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_persists_across_restart(self, mock_config_service):
|
||||
"""Stopping and restarting loads config from service each time."""
|
||||
reset_scheduler_service()
|
||||
svc = SchedulerService()
|
||||
await svc.start()
|
||||
original_time = svc._config.schedule_time
|
||||
assert svc._is_running is True
|
||||
|
||||
await svc.stop()
|
||||
assert svc._is_running is False
|
||||
|
||||
reset_scheduler_service()
|
||||
svc2 = SchedulerService()
|
||||
await svc2.start()
|
||||
assert svc2._is_running is True
|
||||
assert svc2._config.schedule_time == original_time
|
||||
|
||||
await svc2.stop()
|
||||
|
||||
129
tests/unit/test_scheduler_config_model.py
Normal file
129
tests/unit/test_scheduler_config_model.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Unit tests for SchedulerConfig model fields and validators (Task 3)."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.server.models.config import SchedulerConfig
|
||||
|
||||
ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
|
||||
class TestSchedulerConfigDefaults:
|
||||
"""3.1 – Default values."""
|
||||
|
||||
def test_default_schedule_time(self) -> None:
|
||||
config = SchedulerConfig()
|
||||
assert config.schedule_time == "03:00"
|
||||
|
||||
def test_default_schedule_days(self) -> None:
|
||||
config = SchedulerConfig()
|
||||
assert config.schedule_days == ALL_DAYS
|
||||
|
||||
def test_default_auto_download(self) -> None:
|
||||
config = SchedulerConfig()
|
||||
assert config.auto_download_after_rescan is False
|
||||
|
||||
def test_default_enabled(self) -> None:
|
||||
config = SchedulerConfig()
|
||||
assert config.enabled is True
|
||||
|
||||
def test_default_interval_minutes(self) -> None:
|
||||
config = SchedulerConfig()
|
||||
assert config.interval_minutes == 60
|
||||
|
||||
|
||||
class TestSchedulerConfigValidScheduleTime:
|
||||
"""3.2 – Valid schedule_time values."""
|
||||
|
||||
@pytest.mark.parametrize("time_val", ["00:00", "03:00", "12:30", "23:59"])
|
||||
def test_valid_times(self, time_val: str) -> None:
|
||||
config = SchedulerConfig(schedule_time=time_val)
|
||||
assert config.schedule_time == time_val
|
||||
|
||||
|
||||
class TestSchedulerConfigInvalidScheduleTime:
|
||||
"""3.3 – Invalid schedule_time values must raise ValidationError."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time_val",
|
||||
["25:00", "3pm", "", "3:00pm", "24:00", "-1:00", "9:00", "1:60"],
|
||||
)
|
||||
def test_invalid_times(self, time_val: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SchedulerConfig(schedule_time=time_val)
|
||||
|
||||
|
||||
class TestSchedulerConfigValidScheduleDays:
|
||||
"""3.4 – Valid schedule_days values."""
|
||||
|
||||
def test_single_day(self) -> None:
|
||||
config = SchedulerConfig(schedule_days=["mon"])
|
||||
assert config.schedule_days == ["mon"]
|
||||
|
||||
def test_multiple_days(self) -> None:
|
||||
config = SchedulerConfig(schedule_days=["mon", "fri"])
|
||||
assert config.schedule_days == ["mon", "fri"]
|
||||
|
||||
def test_all_days(self) -> None:
|
||||
config = SchedulerConfig(schedule_days=ALL_DAYS)
|
||||
assert config.schedule_days == ALL_DAYS
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
config = SchedulerConfig(schedule_days=[])
|
||||
assert config.schedule_days == []
|
||||
|
||||
|
||||
class TestSchedulerConfigInvalidScheduleDays:
|
||||
"""3.5 – Invalid schedule_days values must raise ValidationError."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"days",
|
||||
[
|
||||
["monday"],
|
||||
["xyz"],
|
||||
["Mon"], # Case-sensitive — must be lowercase
|
||||
[""],
|
||||
],
|
||||
)
|
||||
def test_invalid_days(self, days: list) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SchedulerConfig(schedule_days=days)
|
||||
|
||||
|
||||
class TestSchedulerConfigAutoDownload:
|
||||
"""3.6 – auto_download_after_rescan field."""
|
||||
|
||||
def test_set_true(self) -> None:
|
||||
config = SchedulerConfig(auto_download_after_rescan=True)
|
||||
assert config.auto_download_after_rescan is True
|
||||
|
||||
def test_set_false(self) -> None:
|
||||
config = SchedulerConfig(auto_download_after_rescan=False)
|
||||
assert config.auto_download_after_rescan is False
|
||||
|
||||
|
||||
class TestSchedulerConfigBackwardCompat:
|
||||
"""3.7 – Backward compatibility: old fields still work."""
|
||||
|
||||
def test_legacy_fields_use_defaults(self) -> None:
|
||||
config = SchedulerConfig(enabled=True, interval_minutes=30)
|
||||
assert config.schedule_time == "03:00"
|
||||
assert config.schedule_days == ALL_DAYS
|
||||
assert config.auto_download_after_rescan is False
|
||||
assert config.enabled is True
|
||||
assert config.interval_minutes == 30
|
||||
|
||||
|
||||
class TestSchedulerConfigSerialisation:
|
||||
"""3.8 – Serialisation roundtrip."""
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = SchedulerConfig(
|
||||
enabled=True,
|
||||
interval_minutes=120,
|
||||
schedule_time="04:30",
|
||||
schedule_days=["mon", "wed", "fri"],
|
||||
auto_download_after_rescan=True,
|
||||
)
|
||||
dumped = original.model_dump()
|
||||
restored = SchedulerConfig(**dumped)
|
||||
assert restored == original
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user