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.
260 lines
9.3 KiB
Python
260 lines
9.3 KiB
Python
"""Unit tests for SchedulerConfig model fields and validators (Task 3)."""
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
from src.server.models.config import SchedulerConfig
|
||
|
||
ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||
|
||
|
||
class TestSchedulerConfigDefaults:
|
||
"""3.1 – Default values."""
|
||
|
||
def test_default_schedule_time(self) -> None:
|
||
config = SchedulerConfig()
|
||
assert config.schedule_time == "03:00"
|
||
|
||
def test_default_schedule_days(self) -> None:
|
||
config = SchedulerConfig()
|
||
assert config.schedule_days == ALL_DAYS
|
||
|
||
def test_default_auto_download(self) -> None:
|
||
config = SchedulerConfig()
|
||
assert config.auto_download_after_rescan is False
|
||
|
||
def test_default_enabled(self) -> None:
|
||
config = SchedulerConfig()
|
||
assert config.enabled is True
|
||
|
||
def test_default_interval_minutes(self) -> None:
|
||
config = SchedulerConfig()
|
||
assert config.interval_minutes == 60
|
||
|
||
|
||
class TestSchedulerConfigValidScheduleTime:
|
||
"""3.2 – Valid schedule_time values."""
|
||
|
||
@pytest.mark.parametrize("time_val", ["00:00", "03:00", "12:30", "23:59"])
|
||
def test_valid_times(self, time_val: str) -> None:
|
||
config = SchedulerConfig(schedule_time=time_val)
|
||
assert config.schedule_time == time_val
|
||
|
||
|
||
class TestSchedulerConfigInvalidScheduleTime:
|
||
"""3.3 – Invalid schedule_time values must raise ValidationError."""
|
||
|
||
@pytest.mark.parametrize(
|
||
"time_val",
|
||
["25:00", "3pm", "", "3:00pm", "24:00", "-1:00", "9:00", "1:60"],
|
||
)
|
||
def test_invalid_times(self, time_val: str) -> None:
|
||
with pytest.raises(ValidationError):
|
||
SchedulerConfig(schedule_time=time_val)
|
||
|
||
|
||
class TestSchedulerConfigValidScheduleDays:
|
||
"""3.4 – Valid schedule_days values."""
|
||
|
||
def test_single_day(self) -> None:
|
||
config = SchedulerConfig(schedule_days=["mon"])
|
||
assert config.schedule_days == ["mon"]
|
||
|
||
def test_multiple_days(self) -> None:
|
||
config = SchedulerConfig(schedule_days=["mon", "fri"])
|
||
assert config.schedule_days == ["mon", "fri"]
|
||
|
||
def test_all_days(self) -> None:
|
||
config = SchedulerConfig(schedule_days=ALL_DAYS)
|
||
assert config.schedule_days == ALL_DAYS
|
||
|
||
def test_empty_list(self) -> None:
|
||
config = SchedulerConfig(schedule_days=[])
|
||
assert config.schedule_days == []
|
||
|
||
|
||
class TestSchedulerConfigInvalidScheduleDays:
|
||
"""3.5 – Invalid schedule_days values must raise ValidationError."""
|
||
|
||
@pytest.mark.parametrize(
|
||
"days",
|
||
[
|
||
["monday"],
|
||
["xyz"],
|
||
["Mon"], # Case-sensitive — must be lowercase
|
||
[""],
|
||
],
|
||
)
|
||
def test_invalid_days(self, days: list) -> None:
|
||
with pytest.raises(ValidationError):
|
||
SchedulerConfig(schedule_days=days)
|
||
|
||
|
||
class TestSchedulerConfigAutoDownload:
|
||
"""3.6 – auto_download_after_rescan field."""
|
||
|
||
def test_set_true(self) -> None:
|
||
config = SchedulerConfig(auto_download_after_rescan=True)
|
||
assert config.auto_download_after_rescan is True
|
||
|
||
def test_set_false(self) -> None:
|
||
config = SchedulerConfig(auto_download_after_rescan=False)
|
||
assert config.auto_download_after_rescan is False
|
||
|
||
|
||
class TestSchedulerConfigBackwardCompat:
|
||
"""3.7 – Backward compatibility: old fields still work."""
|
||
|
||
def test_legacy_fields_use_defaults(self) -> None:
|
||
config = SchedulerConfig(enabled=True, interval_minutes=30)
|
||
assert config.schedule_time == "03:00"
|
||
assert config.schedule_days == ALL_DAYS
|
||
assert config.auto_download_after_rescan is False
|
||
assert config.enabled is True
|
||
assert config.interval_minutes == 30
|
||
|
||
|
||
class TestSchedulerConfigFolderScanEnabled:
|
||
"""3.8 – folder_scan_enabled field (Task 1.1)."""
|
||
|
||
def test_default_folder_scan_enabled(self) -> None:
|
||
config = SchedulerConfig()
|
||
assert config.folder_scan_enabled is False
|
||
|
||
def test_set_folder_scan_enabled_true(self) -> None:
|
||
config = SchedulerConfig(folder_scan_enabled=True)
|
||
assert config.folder_scan_enabled is True
|
||
|
||
def test_set_folder_scan_enabled_false(self) -> None:
|
||
config = SchedulerConfig(folder_scan_enabled=False)
|
||
assert config.folder_scan_enabled is False
|
||
|
||
def test_backward_compat_missing_field(self) -> None:
|
||
"""Old configs without folder_scan_enabled load successfully."""
|
||
dumped = {
|
||
"enabled": True,
|
||
"interval_minutes": 60,
|
||
"schedule_time": "03:00",
|
||
"schedule_days": ALL_DAYS,
|
||
"auto_download_after_rescan": False,
|
||
}
|
||
config = SchedulerConfig(**dumped)
|
||
assert config.folder_scan_enabled is False
|
||
|
||
|
||
class TestSchedulerConfigLegacyAliases:
|
||
"""3.10 – Legacy config key aliases (auto_download, folder_scan)."""
|
||
|
||
def test_legacy_auto_download_true(self) -> None:
|
||
"""Legacy auto_download=true maps to auto_download_after_rescan=True."""
|
||
config = SchedulerConfig(auto_download=True)
|
||
assert config.auto_download_after_rescan is True
|
||
assert config.folder_scan_enabled is False
|
||
|
||
def test_legacy_auto_download_false(self) -> None:
|
||
config = SchedulerConfig(auto_download=False)
|
||
assert config.auto_download_after_rescan is False
|
||
|
||
def test_legacy_folder_scan_true(self) -> None:
|
||
"""Legacy folder_scan=true maps to folder_scan_enabled=True."""
|
||
config = SchedulerConfig(folder_scan=True)
|
||
assert config.folder_scan_enabled is True
|
||
assert config.auto_download_after_rescan is False
|
||
|
||
def test_legacy_folder_scan_false(self) -> None:
|
||
config = SchedulerConfig(folder_scan=False)
|
||
assert config.folder_scan_enabled is False
|
||
|
||
def test_legacy_both_set(self) -> None:
|
||
"""Both legacy keys can be set simultaneously."""
|
||
config = SchedulerConfig(auto_download=True, folder_scan=True)
|
||
assert config.auto_download_after_rescan is True
|
||
assert config.folder_scan_enabled is True
|
||
|
||
def test_explicit_primary_overrides_legacy(self) -> None:
|
||
"""Primary field explicitly set to False still wins over legacy True.
|
||
|
||
When user provides both old and new key, newer key wins by virtue of
|
||
being the intended migration target. Legacy alias only applies when
|
||
primary key is absent from data entirely.
|
||
"""
|
||
config = SchedulerConfig(
|
||
auto_download=True,
|
||
auto_download_after_rescan=True,
|
||
folder_scan=True,
|
||
folder_scan_enabled=True,
|
||
)
|
||
# Both set to True — no conflict possible when both agree
|
||
assert config.auto_download_after_rescan is True
|
||
assert config.folder_scan_enabled is True
|
||
|
||
def test_explicit_primary_false_wins_over_legacy_true(self) -> None:
|
||
"""Primary=False explicitly set wins over legacy=True.
|
||
|
||
User has migrated config to new keys but old key still present.
|
||
Explicit primary value must be respected.
|
||
"""
|
||
config = SchedulerConfig(
|
||
auto_download=True,
|
||
auto_download_after_rescan=False,
|
||
)
|
||
assert config.auto_download_after_rescan is False
|
||
|
||
def test_explicit_primary_true_wins_over_legacy_false(self) -> None:
|
||
"""Primary=True explicitly set wins over legacy=False."""
|
||
config = SchedulerConfig(
|
||
auto_download=False,
|
||
auto_download_after_rescan=True,
|
||
)
|
||
assert config.auto_download_after_rescan is True
|
||
|
||
def test_legacy_in_json_dict(self) -> None:
|
||
"""Simulate config.json with legacy auto_download key."""
|
||
data = {
|
||
"enabled": True,
|
||
"schedule_time": "03:00",
|
||
"schedule_days": ALL_DAYS,
|
||
"auto_download": True,
|
||
"folder_scan": True,
|
||
}
|
||
config = SchedulerConfig(**data)
|
||
assert config.auto_download_after_rescan is True
|
||
assert config.folder_scan_enabled is True
|
||
|
||
|
||
class TestSchedulerConfigSerialisation:
|
||
"""3.9 – Serialisation roundtrip."""
|
||
|
||
def test_roundtrip(self) -> None:
|
||
original = SchedulerConfig(
|
||
enabled=True,
|
||
interval_minutes=120,
|
||
schedule_time="04:30",
|
||
schedule_days=["mon", "wed", "fri"],
|
||
auto_download_after_rescan=True,
|
||
folder_scan_enabled=True,
|
||
)
|
||
dumped = original.model_dump()
|
||
restored = SchedulerConfig(**dumped)
|
||
assert restored == original
|
||
|
||
def test_roundtrip_excludes_none_alias_fields(self) -> None:
|
||
"""model_dump must not emit null auto_download/folder_scan keys.
|
||
|
||
Previously these null keys were written to config.json on save.
|
||
On reload they were present (even as None), so the alias mapping in
|
||
__init__ was skipped and the primary fields retained their default
|
||
False values instead of the configured True values.
|
||
"""
|
||
original = SchedulerConfig(
|
||
auto_download_after_rescan=True,
|
||
folder_scan_enabled=True,
|
||
)
|
||
dumped = original.model_dump()
|
||
# Alias fields must not appear when None
|
||
assert "auto_download" not in dumped
|
||
assert "folder_scan" not in dumped
|
||
# Primary fields roundtrip correctly
|
||
restored = SchedulerConfig(**dumped)
|
||
assert restored.auto_download_after_rescan is True
|
||
assert restored.folder_scan_enabled is True
|