Files
Aniworld/tests/unit/test_config_service.py
Lukas 51b7f349f8 fix(scheduler): strip null legacy alias fields from config.json on save
SchedulerConfig.__init__ maps legacy auto_download/folder_scan keys to the
primary auto_download_after_rescan/folder_scan_enabled fields. However,
model_dump() was including auto_download=null and folder_scan=null in
serialised output. When this was written to config.json and reloaded,
those keys were present (albeit null), so the alias mapping was skipped
and the primary fields retained default False values instead of the
configured True values.

Fix:
- Override SchedulerConfig.model_dump() to drop None-valued alias fields
  before returning the serialised dict.
- ConfigService.save_config() re-serialises the scheduler field through
  its overridden model_dump() so the fix applies when writing to disk.

Tests added:
- test_roundtrip_excludes_none_alias_fields: verifies model_dump omits
  null auto_download/folder_scan keys.
- test_save_and_load_scheduler_flags_roundtrip: end-to-end roundtrip
  through ConfigService confirms raw JSON and loaded values match.

Pre-existing failure in test_core_error_handler.py is unrelated.
2026-05-28 21:18:16 +02:00

382 lines
14 KiB
Python

"""Unit tests for ConfigService."""
import json
import tempfile
from pathlib import Path
import pytest
from src.server.models.config import (
AppConfig,
BackupConfig,
ConfigUpdate,
LoggingConfig,
SchedulerConfig,
)
from src.server.services.config_service import (
ConfigBackupError,
ConfigService,
ConfigServiceError,
ConfigValidationError,
)
@pytest.fixture
def temp_dir():
"""Create temporary directory for test config files."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def config_service(temp_dir):
"""Create ConfigService instance with temporary paths."""
config_path = temp_dir / "config.json"
backup_dir = temp_dir / "backups"
return ConfigService(
config_path=config_path, backup_dir=backup_dir, max_backups=3
)
@pytest.fixture
def sample_config():
"""Create sample configuration."""
return AppConfig(
name="TestApp",
data_dir="test_data",
scheduler=SchedulerConfig(enabled=True, interval_minutes=30),
logging=LoggingConfig(level="DEBUG", file="test.log"),
backup=BackupConfig(enabled=False),
other={"custom_key": "custom_value"},
)
class TestConfigServiceInitialization:
"""Test ConfigService initialization and directory creation."""
def test_initialization_creates_directories(self, temp_dir):
"""Test that initialization creates necessary directories."""
config_path = temp_dir / "subdir" / "config.json"
backup_dir = temp_dir / "subdir" / "backups"
service = ConfigService(config_path=config_path, backup_dir=backup_dir)
assert config_path.parent.exists()
assert backup_dir.exists()
assert service.config_path == config_path
assert service.backup_dir == backup_dir
def test_initialization_with_existing_directories(self, config_service):
"""Test initialization with existing directories works."""
assert config_service.config_path.parent.exists()
assert config_service.backup_dir.exists()
class TestConfigServiceLoadSave:
"""Test configuration loading and saving."""
def test_load_creates_default_config_if_not_exists(self, config_service):
"""Test that load creates default config if file doesn't exist."""
config = config_service.load_config()
assert isinstance(config, AppConfig)
assert config.name == "Aniworld"
assert config_service.config_path.exists()
def test_save_and_load_config(self, config_service, sample_config):
"""Test saving and loading configuration."""
config_service.save_config(sample_config, create_backup=False)
loaded_config = config_service.load_config()
assert loaded_config.name == sample_config.name
assert loaded_config.data_dir == sample_config.data_dir
assert loaded_config.scheduler.enabled == sample_config.scheduler.enabled
assert loaded_config.logging.level == sample_config.logging.level
assert loaded_config.other == sample_config.other
def test_save_and_load_scheduler_flags_roundtrip(self, config_service):
"""Scheduler auto_download_after_rescan and folder_scan_enabled must
survive a full save/load roundtrip through ConfigService.
Regression test for a bug where null legacy alias fields
(auto_download=None, folder_scan=None) were written to config.json
on save. On reload the alias mapping was skipped (because the keys
were present), causing the primary boolean fields to reset to False.
"""
original = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
folder_scan_enabled=True,
)
)
config_service.save_config(original, create_backup=False)
# Verify raw JSON does not contain legacy alias keys
with open(config_service.config_path, "r", encoding="utf-8") as f:
raw = json.load(f)
assert "auto_download" not in raw["scheduler"]
assert "folder_scan" not in raw["scheduler"]
assert raw["scheduler"]["auto_download_after_rescan"] is True
assert raw["scheduler"]["folder_scan_enabled"] is True
# Verify loaded config preserves values
loaded = config_service.load_config()
assert loaded.scheduler.auto_download_after_rescan is True
assert loaded.scheduler.folder_scan_enabled is True
def test_save_includes_version(self, config_service, sample_config):
"""Test that saved config includes version field."""
config_service.save_config(sample_config, create_backup=False)
with open(config_service.config_path, "r", encoding="utf-8") as f:
data = json.load(f)
assert "version" in data
assert data["version"] == ConfigService.CONFIG_VERSION
def test_save_creates_backup_by_default(self, config_service, sample_config):
"""Test that save creates backup by default if file exists."""
# Save initial config
config_service.save_config(sample_config, create_backup=False)
# Modify and save again (should create backup)
sample_config.name = "Modified"
config_service.save_config(sample_config, create_backup=True)
backups = list(config_service.backup_dir.glob("*.json"))
assert len(backups) == 1
def test_save_atomic_operation(self, config_service, sample_config):
"""Test that save is atomic (uses temp file)."""
# Mock exception during JSON dump by using invalid data
# This should not corrupt existing config
config_service.save_config(sample_config, create_backup=False)
# Verify temp file is cleaned up after successful save
temp_files = list(config_service.config_path.parent.glob("*.tmp"))
assert len(temp_files) == 0
def test_load_invalid_json_raises_error(self, config_service):
"""Test that loading invalid JSON raises ConfigValidationError."""
# Write invalid JSON
config_service.config_path.write_text("invalid json {")
with pytest.raises(ConfigValidationError, match="Invalid JSON"):
config_service.load_config()
class TestConfigServiceValidation:
"""Test configuration validation."""
def test_validate_valid_config(self, config_service, sample_config):
"""Test validation of valid configuration."""
result = config_service.validate_config(sample_config)
assert result.valid is True
assert result.errors == []
def test_validate_invalid_config(self, config_service):
"""Test validation of invalid configuration."""
# Create config with backups enabled but no path
invalid_config = AppConfig(
backup=BackupConfig(enabled=True, path=None)
)
result = config_service.validate_config(invalid_config)
assert result.valid is False
assert len(result.errors or []) > 0
def test_save_invalid_config_raises_error(self, config_service):
"""Test that saving invalid config raises error."""
invalid_config = AppConfig(
backup=BackupConfig(enabled=True, path=None)
)
with pytest.raises(ConfigValidationError, match="Cannot save invalid"):
config_service.save_config(invalid_config)
class TestConfigServiceUpdate:
"""Test configuration updates."""
def test_update_config(self, config_service, sample_config):
"""Test updating configuration."""
config_service.save_config(sample_config, create_backup=False)
update = ConfigUpdate(
scheduler=SchedulerConfig(enabled=False, interval_minutes=60),
logging=LoggingConfig(level="INFO"),
)
updated_config = config_service.update_config(update)
assert updated_config.scheduler.enabled is False
assert updated_config.scheduler.interval_minutes == 60
assert updated_config.logging.level == "INFO"
# Other fields should remain unchanged
assert updated_config.name == sample_config.name
assert updated_config.data_dir == sample_config.data_dir
def test_update_persists_changes(self, config_service, sample_config):
"""Test that updates are persisted to disk."""
config_service.save_config(sample_config, create_backup=False)
update = ConfigUpdate(logging=LoggingConfig(level="ERROR"))
config_service.update_config(update)
# Load fresh config from disk
loaded = config_service.load_config()
assert loaded.logging.level == "ERROR"
class TestConfigServiceBackups:
"""Test configuration backup functionality."""
def test_create_backup(self, config_service, sample_config):
"""Test creating configuration backup."""
config_service.save_config(sample_config, create_backup=False)
backup_path = config_service.create_backup()
assert backup_path.exists()
assert backup_path.suffix == ".json"
assert "config_backup_" in backup_path.name
def test_create_backup_with_custom_name(
self, config_service, sample_config
):
"""Test creating backup with custom name."""
config_service.save_config(sample_config, create_backup=False)
backup_path = config_service.create_backup(name="my_backup")
assert backup_path.name == "my_backup.json"
def test_create_backup_without_config_raises_error(self, config_service):
"""Test that creating backup without config file raises error."""
with pytest.raises(ConfigBackupError, match="Cannot backup non-existent"):
config_service.create_backup()
def test_list_backups(self, config_service, sample_config):
"""Test listing configuration backups."""
config_service.save_config(sample_config, create_backup=False)
# Create multiple backups
config_service.create_backup(name="backup1")
config_service.create_backup(name="backup2")
config_service.create_backup(name="backup3")
backups = config_service.list_backups()
assert len(backups) == 3
assert all("name" in b for b in backups)
assert all("size_bytes" in b for b in backups)
assert all("created_at" in b for b in backups)
# Should be sorted by creation time (newest first)
backup_names = [b["name"] for b in backups]
assert "backup3.json" in backup_names
def test_list_backups_empty(self, config_service):
"""Test listing backups when none exist."""
backups = config_service.list_backups()
assert backups == []
def test_restore_backup(self, config_service, sample_config):
"""Test restoring configuration from backup."""
# Save initial config and create backup
config_service.save_config(sample_config, create_backup=False)
config_service.create_backup(name="original")
# Modify and save config
sample_config.name = "Modified"
config_service.save_config(sample_config, create_backup=False)
# Restore from backup
restored = config_service.restore_backup("original.json")
assert restored.name == "TestApp" # Original name
def test_restore_backup_creates_pre_restore_backup(
self, config_service, sample_config
):
"""Test that restore creates pre-restore backup."""
config_service.save_config(sample_config, create_backup=False)
config_service.create_backup(name="backup1")
sample_config.name = "Modified"
config_service.save_config(sample_config, create_backup=False)
config_service.restore_backup("backup1.json")
backups = config_service.list_backups()
backup_names = [b["name"] for b in backups]
assert any("pre_restore" in name for name in backup_names)
def test_restore_nonexistent_backup_raises_error(self, config_service):
"""Test that restoring non-existent backup raises error."""
with pytest.raises(ConfigBackupError, match="Backup not found"):
config_service.restore_backup("nonexistent.json")
def test_delete_backup(self, config_service, sample_config):
"""Test deleting configuration backup."""
config_service.save_config(sample_config, create_backup=False)
config_service.create_backup(name="to_delete")
config_service.delete_backup("to_delete.json")
backups = config_service.list_backups()
assert len(backups) == 0
def test_delete_nonexistent_backup_raises_error(self, config_service):
"""Test that deleting non-existent backup raises error."""
with pytest.raises(ConfigBackupError, match="Backup not found"):
config_service.delete_backup("nonexistent.json")
def test_cleanup_old_backups(self, config_service, sample_config):
"""Test that old backups are cleaned up when limit exceeded."""
config_service.save_config(sample_config, create_backup=False)
# Create more backups than max_backups (3)
for i in range(5):
config_service.create_backup(name=f"backup{i}")
backups = config_service.list_backups()
assert len(backups) == 3 # Should only keep max_backups
class TestConfigServiceSingleton:
"""Test singleton instance management."""
def test_get_config_service_returns_singleton(self):
"""Test that get_config_service returns same instance."""
from src.server.services.config_service import get_config_service
service1 = get_config_service()
service2 = get_config_service()
assert service1 is service2
class TestConfigServiceErrorHandling:
"""Test error handling in ConfigService."""
def test_save_config_creates_temp_file(
self, config_service, sample_config
):
"""Test that save operation uses temporary file."""
# Save config and verify temp file is cleaned up
config_service.save_config(sample_config, create_backup=False)
# Verify no temp files remain
temp_files = list(config_service.config_path.parent.glob("*.tmp"))
assert len(temp_files) == 0
# Verify config was saved successfully
loaded = config_service.load_config()
assert loaded.name == sample_config.name