backend/tests/test_config_refactor_safety.py
Rama master · réplica de lectura del repositorio original.
"""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_<ui_field> 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_<ui_field> 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