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()
|
||||
|
||||
Reference in New Issue
Block a user