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