Files
Aniworld/tests/api/test_config_endpoints.py
Lukas dfc28b8e66 fix(scheduler): ensure scheduler starts after setup/config update
Add ensure_started() to SchedulerService as idempotent entry point.
Start scheduler in auth setup run_initialization() after NFO scan.
Sync anime_directory and start scheduler in config update endpoint.
Add unit and endpoint tests for ensure_started() behavior.
2026-05-26 13:23:48 +02:00

253 lines
8.5 KiB
Python

"""Integration tests for configuration API endpoints."""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.models.config import AppConfig
from src.server.services.auth_service import auth_service
from src.server.services.config_service import ConfigService
@pytest.fixture
def temp_config_dir():
"""Create temporary directory for test config files."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def config_service(temp_config_dir):
"""Create ConfigService instance with temporary paths."""
config_path = temp_config_dir / "config.json"
backup_dir = temp_config_dir / "backups"
return ConfigService(
config_path=config_path, backup_dir=backup_dir, max_backups=3
)
@pytest.fixture
def mock_config_service(config_service):
"""Mock get_config_service to return test instance."""
with patch(
"src.server.api.config.get_config_service",
return_value=config_service
):
yield config_service
@pytest.fixture
async def client():
"""Create 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():
"""Create authenticated async test client."""
# Setup auth if not configured
if not auth_service.is_configured():
auth_service.setup_master_password("TestPass123!")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
# Login to get token
r = await ac.post("/api/auth/login", json={"password": "TestPass123!"})
if r.status_code == 200:
token = r.json()["access_token"]
ac.headers["Authorization"] = f"Bearer {token}"
yield ac
@pytest.mark.asyncio
async def test_get_config_public(authenticated_client, mock_config_service):
"""Test getting configuration."""
resp = await authenticated_client.get("/api/config")
assert resp.status_code == 200
data = resp.json()
assert "name" in data
assert "data_dir" in data
@pytest.mark.asyncio
async def test_validate_config(authenticated_client, mock_config_service):
"""Test configuration validation."""
cfg = {
"name": "Aniworld",
"data_dir": "data",
"scheduler": {"enabled": True, "interval_minutes": 30},
"logging": {"level": "INFO"},
"backup": {"enabled": False},
"other": {},
}
resp = await authenticated_client.post("/api/config/validate", json=cfg)
assert resp.status_code == 200
body = resp.json()
assert body.get("valid") is True
@pytest.mark.asyncio
async def test_validate_invalid_config(authenticated_client, mock_config_service):
"""Test validation of invalid configuration."""
cfg = {
"name": "Aniworld",
"backup": {"enabled": True, "path": None}, # Invalid
}
resp = await authenticated_client.post("/api/config/validate", json=cfg)
assert resp.status_code == 200
body = resp.json()
assert body.get("valid") is False
assert len(body.get("errors", [])) > 0
@pytest.mark.asyncio
async def test_update_config_unauthorized(client):
"""Test that update requires authentication."""
update = {"scheduler": {"enabled": False}}
resp = await client.put("/api/config", json=update)
assert resp.status_code in (401, 422)
@pytest.mark.asyncio
async def test_list_backups(authenticated_client, mock_config_service):
"""Test listing configuration backups."""
# Create a sample config first
sample_config = AppConfig(name="TestApp", data_dir="test_data")
mock_config_service.save_config(sample_config, create_backup=False)
mock_config_service.create_backup(name="test_backup")
resp = await authenticated_client.get("/api/config/backups")
assert resp.status_code == 200
backups = resp.json()
assert isinstance(backups, list)
if len(backups) > 0:
assert "name" in backups[0]
assert "size_bytes" in backups[0]
assert "created_at" in backups[0]
@pytest.mark.asyncio
async def test_create_backup(authenticated_client, mock_config_service):
"""Test creating a configuration backup."""
# Create a sample config first
sample_config = AppConfig(name="TestApp", data_dir="test_data")
mock_config_service.save_config(sample_config, create_backup=False)
resp = await authenticated_client.post("/api/config/backups")
assert resp.status_code == 200
data = resp.json()
assert "name" in data
assert "message" in data
@pytest.mark.asyncio
async def test_restore_backup(authenticated_client, mock_config_service):
"""Test restoring configuration from backup."""
# Create initial config and backup
sample_config = AppConfig(name="TestApp", data_dir="test_data")
mock_config_service.save_config(sample_config, create_backup=False)
mock_config_service.create_backup(name="restore_test")
# Modify config
sample_config.name = "Modified"
mock_config_service.save_config(sample_config, create_backup=False)
# Restore from backup
resp = await authenticated_client.post("/api/config/backups/restore_test.json/restore")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "TestApp" # Original name restored
@pytest.mark.asyncio
async def test_delete_backup(authenticated_client, mock_config_service):
"""Test deleting a configuration backup."""
# Create a sample config and backup
sample_config = AppConfig(name="TestApp", data_dir="test_data")
mock_config_service.save_config(sample_config, create_backup=False)
mock_config_service.create_backup(name="delete_test")
resp = await authenticated_client.delete("/api/config/backups/delete_test.json")
assert resp.status_code == 200
data = resp.json()
assert "deleted successfully" in data["message"]
@pytest.mark.asyncio
async def test_config_persistence(authenticated_client, mock_config_service):
"""Test end-to-end configuration persistence."""
# Get initial config
resp = await authenticated_client.get("/api/config")
assert resp.status_code == 200
initial = resp.json()
# Validate it can be loaded again
resp2 = await authenticated_client.get("/api/config")
assert resp2.status_code == 200
assert resp2.json() == initial
@pytest.mark.asyncio
async def test_tmdb_validation_endpoint_exists(authenticated_client):
"""Test TMDB validation endpoint exists and is callable."""
resp = await authenticated_client.post(
"/api/config/tmdb/validate",
json={"api_key": ""}
)
assert resp.status_code == 200
data = resp.json()
assert "valid" in data
assert "message" in data
assert data["valid"] is False # Empty key should be invalid
assert "required" in data["message"].lower()
@pytest.mark.asyncio
async def test_update_config_with_anime_directory_starts_scheduler(
authenticated_client, mock_config_service
):
"""PUT /api/config with anime_directory syncs and starts scheduler."""
mock_scheduler = AsyncMock()
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
mock_sched_fn.return_value = mock_scheduler
with patch("src.config.settings.settings") as mock_settings:
mock_settings.anime_directory = None
resp = await authenticated_client.put(
"/api/config",
json={"other": {"anime_directory": "/data/anime"}},
)
assert resp.status_code == 200
mock_scheduler.ensure_started.assert_called_once()
@pytest.mark.asyncio
async def test_update_config_without_anime_directory_does_not_start_scheduler(
authenticated_client, mock_config_service
):
"""PUT /api/config without new anime_directory does not call scheduler.ensure_started()."""
mock_scheduler = AsyncMock()
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
mock_sched_fn.return_value = mock_scheduler
with patch("src.config.settings.settings") as mock_settings:
mock_settings.anime_directory = "/already/set"
resp = await authenticated_client.put(
"/api/config", json={"other": {}}
)
assert resp.status_code == 200
mock_scheduler.ensure_started.assert_not_called()