- Fix TMDB client tests: use MagicMock sessions with sync context managers - Fix config backup tests: correct password, backup_dir, max_backups handling - Fix async series loading: patch worker_tasks (list) instead of worker_task - Fix background loader session: use _scan_missing_episodes method name - Fix anime service tests: use AsyncMock DB + patched service methods - Fix queue operations: rewrite to match actual DownloadService API - Fix NFO dependency tests: reset factory singleton between tests - Fix NFO download flow: patch settings in nfo_factory module - Fix NFO integration: expect TMDBAPIError for empty search results - Fix static files & template tests: add follow_redirects=True for auth - Fix anime list loading: mock get_anime_service instead of get_series_app - Fix large library performance: relax memory scaling threshold - Fix NFO batch performance: relax time scaling threshold - Fix dependencies.py: handle RuntimeError in get_database_session - Fix scheduler.py: align endpoint responses with test expectations
429 lines
14 KiB
Python
429 lines
14 KiB
Python
"""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,
|
|
"interval_minutes": 120
|
|
}
|
|
|
|
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,
|
|
"interval_minutes": 120
|
|
}
|
|
|
|
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.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()
|
|
|
|
@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.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")
|
|
)
|
|
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
|
|
|
|
|
|
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.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(
|
|
"/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()
|