Files
odysseus/tests/test_settings_error_paths.py
Lucas Daniel 398892cced fix(settings): catch PermissionError in load_settings + error-path tests (#1570)
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.
2026-06-03 14:23:27 +09:00

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