PermissionError was not in the except tuple so an unreadable settings.json would crash the app instead of falling back to defaults. Added alongside the existing FileNotFoundError/JSONDecodeError/ValueError catches. Also adds test_settings_error_paths.py covering all four failure modes: missing file, corrupted JSON, wrong type, and permission denied.
95 lines
3.4 KiB
Python
95 lines
3.4 KiB
Python
"""Error-path tests for src/settings.py load_settings().
|
|
|
|
Covers the fallback-to-defaults behaviour when the settings file is
|
|
missing, corrupt, or unreadable — including the PermissionError case
|
|
that was previously uncaught and would crash the app.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
_TMP = Path(tempfile.mkdtemp(prefix="odysseus-settings-test-"))
|
|
os.environ.setdefault("DATA_DIR", str(_TMP))
|
|
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TMP / 'app.db'}")
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
def _fresh_load(settings_path, content=None):
|
|
"""Write content to settings_path, clear cache, and call load_settings()."""
|
|
import src.settings as s
|
|
|
|
if content is not None:
|
|
settings_path.write_text(content, encoding="utf-8")
|
|
|
|
# Force cache invalidation so each test reads fresh from disk.
|
|
s._settings_cache = None
|
|
with patch.object(s, "SETTINGS_FILE", str(settings_path)):
|
|
return s.load_settings()
|
|
|
|
|
|
def test_missing_file_returns_defaults(tmp_path):
|
|
"""FileNotFoundError → defaults, no crash."""
|
|
import src.settings as s
|
|
missing = tmp_path / "nonexistent_settings.json"
|
|
s._settings_cache = None
|
|
with patch.object(s, "SETTINGS_FILE", str(missing)):
|
|
result = s.load_settings()
|
|
assert isinstance(result, dict)
|
|
assert result == {**s.DEFAULT_SETTINGS, **result} # superset of defaults
|
|
|
|
|
|
def test_corrupted_json_returns_defaults(tmp_path):
|
|
"""Invalid JSON → defaults, no crash."""
|
|
result = _fresh_load(tmp_path / "settings.json", content="{not valid json")
|
|
import src.settings as s
|
|
assert result == {**s.DEFAULT_SETTINGS, **result}
|
|
|
|
|
|
def test_wrong_type_returns_defaults(tmp_path):
|
|
"""JSON array instead of object → defaults, no crash."""
|
|
result = _fresh_load(tmp_path / "settings.json", content="[1, 2, 3]")
|
|
import src.settings as s
|
|
assert result == {**s.DEFAULT_SETTINGS, **result}
|
|
|
|
|
|
def test_permission_error_returns_defaults(tmp_path):
|
|
"""PermissionError on unreadable file → defaults, no crash.
|
|
|
|
Pre-fix: PermissionError was not in the except tuple, so it would
|
|
propagate and crash any code path that calls load_settings() at
|
|
startup or request time.
|
|
"""
|
|
import src.settings as s
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text('{"theme": "dark"}', encoding="utf-8")
|
|
|
|
s._settings_cache = None
|
|
with patch.object(s, "SETTINGS_FILE", str(settings_path)):
|
|
# Simulate unreadable file by patching open() to raise PermissionError.
|
|
with patch("builtins.open", side_effect=PermissionError("Permission denied")):
|
|
result = s.load_settings()
|
|
|
|
assert isinstance(result, dict), "Should return defaults dict, not raise"
|
|
assert result == {**s.DEFAULT_SETTINGS, **result}
|
|
|
|
|
|
def test_valid_settings_merged_with_defaults(tmp_path):
|
|
"""Valid file → custom values merged over defaults."""
|
|
import src.settings as s
|
|
result = _fresh_load(
|
|
tmp_path / "settings.json",
|
|
content=json.dumps({"theme": "dark", "web_search_enabled": True}),
|
|
)
|
|
assert result["theme"] == "dark"
|
|
assert result["web_search_enabled"] is True
|
|
# Defaults still present for keys not in file.
|
|
for key in s.DEFAULT_SETTINGS:
|
|
assert key in result
|