"""Characterization tests for backend/config.py refactor (B1). These tests pin behaviour of the parts of config.py NOT yet covered by test_config.py / test_config_split.py / test_settings_views.py. After every extraction step in plan 2026-04-18-b1-config-py-split.md this file MUST keep passing without modification. """ import pytest def test_settings_class_importable_from_config(): """Settings (the composite class) must be importable from `config`.""" from config import BootSettings, Settings, UISettings assert Settings is not None # Composite — model_dump is implemented (forwards to boot+ui). assert hasattr(Settings, "model_dump") # Boot/UI sub-classes also re-exported for direct construction. assert BootSettings is not None assert UISettings is not None def test_settings_instantiable_without_env(): """Default-constructed Settings produces sensible values.""" from config import Settings s = Settings() assert s.port == 5765 assert s.target_language == "de" assert s.source_language == "en" def test_get_database_url_sqlite_default(): from config import Settings s = Settings(database_url="", db_path="/tmp/x.db") assert s.get_database_url() == "sqlite:////tmp/x.db" def test_get_database_url_explicit_takes_precedence(): from config import Settings s = Settings(database_url="postgresql://u:p@h/db", db_path="/tmp/x.db") assert s.get_database_url() == "postgresql://u:p@h/db" def test_get_target_patterns_returns_dotted_extensions(): from config import Settings s = Settings(target_language="de") patterns = s.get_target_patterns(fmt="ass") # Must include at least the iso 639-1 form assert ".de.ass" in patterns # And start with a dot assert all(p.startswith(".") for p in patterns) def test_get_source_patterns_uses_source_language(): from config import Settings s = Settings(source_language="en") patterns = s.get_source_patterns(fmt="srt") assert ".en.srt" in patterns def test_get_target_lang_tags_returns_set_of_strings(): from config import Settings s = Settings(target_language="de") tags = s.get_target_lang_tags() assert isinstance(tags, set) assert "de" in tags def test_get_source_lang_tags_returns_set_of_strings(): from config import Settings s = Settings(source_language="en") tags = s.get_source_lang_tags() assert isinstance(tags, set) assert "en" in tags def test_get_translation_config_hash_is_12_chars_hex(): from config import Settings s = Settings() h = s.get_translation_config_hash() assert isinstance(h, str) assert len(h) == 12 int(h, 16) # raises if not hex def test_get_translation_config_hash_changes_with_model(): from config import Settings s1 = Settings(ollama_model="qwen2.5:14b-instruct") s2 = Settings(ollama_model="llama3:8b") assert s1.get_translation_config_hash() != s2.get_translation_config_hash() def test_get_translation_config_hash_non_ollama_ignores_model(): from config import Settings s1 = Settings(ollama_model="model-a") s2 = Settings(ollama_model="model-b") # For non-ollama backend the model field is irrelevant. assert s1.get_translation_config_hash("deepl") == s2.get_translation_config_hash("deepl") def test_get_safe_config_masks_api_keys(): from config import Settings s = Settings(opensubtitles_api_key="SECRET123", subdl_api_key="OTHER456") safe = s.get_safe_config() assert safe["opensubtitles_api_key"] == "***configured***" assert safe["subdl_api_key"] == "***configured***" def test_get_safe_config_passes_through_non_sensitive(): from config import Settings s = Settings(port=5765, target_language="de") safe = s.get_safe_config() assert safe["port"] == 5765 assert safe["target_language"] == "de" def test_get_safe_config_masks_passwords(): from config import Settings s = Settings(addic7ed_password="hunter2") safe = s.get_safe_config() assert safe["addic7ed_password"] == "***configured***" def test_get_safe_config_masks_database_url_when_set(): from config import Settings s = Settings(database_url="postgresql://user:pw@host/db") safe = s.get_safe_config() assert safe["database_url"] == "***configured***" def test_get_safe_config_empty_credentials_stay_empty_string(): from config import Settings s = Settings(opensubtitles_api_key="") safe = s.get_safe_config() assert safe["opensubtitles_api_key"] == "" def test_get_safe_config_masks_all_credential_subkeys_in_arr_instances_json(): import json as _json from config import Settings payload = _json.dumps( [ { "name": "Main", "url": "http://s/", "api_key": "A", "apiKey": "B", "password": "C", "token": "D", "secret": "E", "pin": "F", } ] ) s = Settings(sonarr_instances_json=payload) safe = s.get_safe_config() parsed = _json.loads(safe["sonarr_instances_json"]) for subkey in ("api_key", "apiKey", "password", "token", "secret", "pin"): assert parsed[0][subkey] == "***configured***", f"subkey {subkey!r} not masked" assert parsed[0]["name"] == "Main" assert parsed[0]["url"] == "http://s/" def test_get_settings_returns_singleton(): from config import get_settings s1 = get_settings() s2 = get_settings() assert s1 is s2 def test_reload_settings_replaces_singleton(): from config import get_settings, reload_settings s1 = get_settings() s2 = reload_settings() # The singleton now points at the reloaded instance. assert get_settings() is s2 # reload_settings() always constructs a fresh Settings(); not the prior cache. assert s2 is not s1 def test_reload_settings_applies_overrides_with_type_coercion(): """DB overrides apply to UI fields with type coercion. Boot fields (port, log_level, paths, …) are ENV-only since v0.88.0-beta; reload_settings drops boot keys silently. Use UI fields here to exercise the type-coercion path. """ from config import reload_settings s = reload_settings(overrides={"items_per_page": "75", "wanted_anime_only": "false"}) assert s.items_per_page == 75 # str → int assert s.wanted_anime_only is False # str → bool def test_reload_settings_ignores_unknown_keys(): from config import reload_settings s = reload_settings(overrides={"there_is_no_such_field_xyz": "1"}) assert not hasattr(s, "there_is_no_such_field_xyz") def test_reload_settings_skips_invalid_values(): from config import reload_settings # "abc" cannot be parsed into the int field `items_per_page` — must be # silently skipped so the rest of the settings still load. s = reload_settings(overrides={"items_per_page": "abc"}) assert s.items_per_page == 25 # default preserved def test_grouped_view_classes_importable_from_config(): """Already covered by test_settings_views.py but pinned here too for safety.""" from config import ( GeneralSettings, MediaServerSettings, ProviderSettings, ScanningSettings, TranslationSettings, ) for cls in ( GeneralSettings, TranslationSettings, ProviderSettings, MediaServerSettings, ScanningSettings, ): assert cls is not None assert hasattr(cls, "_fields") assert isinstance(cls._fields, frozenset) assert len(cls._fields) > 0 def test_warn_on_ignored_env_vars_warns_for_ui_fields(monkeypatch, caplog): """SUBLARR_ env vars trigger a per-key warning + are ignored. Boot-field env vars stay silent. ``warn_on_ignored_env_vars`` returns the list of ignored variable names so startup metrics / tests can assert on the count. """ import logging from config import warn_on_ignored_env_vars monkeypatch.setenv("SUBLARR_OPENSUBTITLES_API_KEY", "fake") # ui — warned monkeypatch.setenv("SUBLARR_TRANSLATION_ENABLED", "true") # ui — warned monkeypatch.setenv("SUBLARR_API_KEY", "boot-stays-silent") # boot — silent monkeypatch.setenv("SUBLARR_LOG_LEVEL", "DEBUG") # boot — silent monkeypatch.setenv("UNRELATED_VAR", "x") # not SUBLARR_-prefixed with caplog.at_level(logging.WARNING, logger="config_settings"): ignored = warn_on_ignored_env_vars() assert sorted(ignored) == [ "SUBLARR_OPENSUBTITLES_API_KEY", "SUBLARR_TRANSLATION_ENABLED", ] # Each ignored var produced its own warning log line. warning_messages = [r.getMessage() for r in caplog.records if r.levelname == "WARNING"] assert any("SUBLARR_OPENSUBTITLES_API_KEY" in m for m in warning_messages) assert any("SUBLARR_TRANSLATION_ENABLED" in m for m in warning_messages) # Boot-field env vars must not surface in the warning stream. assert not any("SUBLARR_API_KEY" in m for m in warning_messages) def test_warn_on_ignored_env_vars_returns_empty_when_clean(monkeypatch): """No SUBLARR_ env vars set → empty list, no warnings.""" from config import UISettings, warn_on_ignored_env_vars # Clear any pre-existing UI-field SUBLARR_ env vars from the host shell. for env_name in list(monkeypatch._setenv if hasattr(monkeypatch, "_setenv") else ()): pass # monkeypatch does its own cleanup at fixture teardown import os for env_name in [k for k in os.environ if k.startswith("SUBLARR_")]: field_name = env_name.removeprefix("SUBLARR_").lower() if field_name in UISettings.model_fields: monkeypatch.delenv(env_name, raising=False) ignored = warn_on_ignored_env_vars() assert ignored == [] def test_config_py_under_600_loc(): """Pin B1 achievement: config.py must stay below 600 LOC. If you are adding settings, put new fields in config_settings.py. If you are adding view classes, put them in config_views.py. config.py is intentionally a thin re-export façade. """ from pathlib import Path config_path = Path(__file__).parent.parent / "config.py" assert config_path.exists(), f"config.py not found at {config_path}" line_count = sum(1 for _ in config_path.open(encoding="utf-8")) assert line_count < 600, ( f"backend/config.py is {line_count} LOC, must stay below 600. " "Move declarations into config_settings.py or config_views.py." ) @pytest.fixture(autouse=True) def _reset_singleton_after_test(): """Make sure singleton state from this file does not leak into other tests.""" yield # Force a clean reload so subsequent test files see a fresh Settings() from config import reload_settings reload_settings() def test_peek_settings_never_builds_the_singleton(): """``peek_settings`` exists so callers outside an app context stay safe. ``get_settings`` builds the singleton by overlaying the database entries on the defaults, which needs a Flask application context. A caller without one — WSGI middleware is the case this was written for — would cache a Settings object with every stored setting missing, and every later reader would get that object. ``peek_settings`` answers ``None`` instead of building, and this test is what keeps it that way. """ import config_singleton from config import peek_settings before = config_singleton._settings try: config_singleton._settings = None assert peek_settings() is None assert config_singleton._settings is None, "peek_settings built the singleton" finally: config_singleton._settings = before def test_peek_settings_returns_the_built_instance(): from config import get_settings, peek_settings built = get_settings() assert peek_settings() is built