"""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()