diff --git a/tests/api/test_scheduler_endpoints.py b/tests/api/test_scheduler_endpoints.py new file mode 100644 index 0000000..150dfa8 --- /dev/null +++ b/tests/api/test_scheduler_endpoints.py @@ -0,0 +1,430 @@ +"""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.""" + # 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.""" + service = Mock() + + # Mock config object with scheduler section + config = Mock() + config.scheduler = SchedulerConfig( + enabled=True, + interval_minutes=60 + ) + + 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 + + +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 + ): + """Test successful scheduler configuration retrieval.""" + with patch( + 'src.server.api.scheduler.get_config_service', + return_value=mock_config_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() + + @pytest.mark.asyncio + async def test_get_scheduler_config_unauthorized(self, client): + """Test scheduler config retrieval without authentication.""" + 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 + ): + """Test scheduler config retrieval when config loading fails.""" + 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 + ): + response = await authenticated_client.get("/api/scheduler/config") + + assert response.status_code == 500 + assert "Failed to load scheduler configuration" in response.text + + +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 + ): + """Test successful scheduler configuration update.""" + 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 == 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() + + @pytest.mark.asyncio + async def test_update_scheduler_config_unauthorized(self, client): + """Test scheduler config update without authentication.""" + new_config = { + "enabled": False, + "rescan_interval_hours": 48, + "rescan_on_startup": True + } + + response = await client.post( + "/api/scheduler/config", + json=new_config + ) + 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) + } + + response = await authenticated_client.post( + "/api/scheduler/config", + json=invalid_config + ) + # 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" + ) + + 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 + + @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 + + @pytest.mark.asyncio + async def test_update_scheduler_interval_validation( + self, + authenticated_client, + mock_config_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) + response = await authenticated_client.post( + "/api/scheduler/config", + json={ + "enabled": True, + "interval_minutes": 1 + } + ) + 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 + + +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.""" + mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"}) + mock_series_app = Mock() + + with patch( + 'src.server.api.scheduler.get_series_app', + return_value=mock_series_app + ), patch( + 'src.server.api.scheduler.do_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() + + @pytest.mark.asyncio + async def test_trigger_rescan_unauthorized(self, client): + """Test manual rescan trigger without authentication.""" + 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 + ): + """Test manual rescan trigger when SeriesApp not initialized.""" + with patch( + 'src.server.api.scheduler.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") + ) + mock_series_app = Mock() + + with patch( + 'src.server.api.scheduler.get_series_app', + return_value=mock_series_app + ), patch( + 'src.server.api.scheduler.do_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 + + +class TestSchedulerEndpointsIntegration: + """Integration tests for scheduler endpoints.""" + + @pytest.mark.asyncio + async def test_full_config_workflow( + self, + authenticated_client, + mock_config_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( + "/api/scheduler/config", + json=new_config + ) + 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() + + @pytest.mark.asyncio + async def test_trigger_rescan_after_config_update( + self, + authenticated_client, + mock_config_service + ): + """Test triggering rescan after updating config.""" + 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_series_app', + return_value=mock_series_app + ), patch( + 'src.server.api.scheduler.do_rescan', + mock_trigger + ): + # Update config to enable scheduler + response = await authenticated_client.post( + "/api/scheduler/config", + 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 + mock_trigger.assert_called_once()