Files
learningcircuit--local-deep…/tests/settings/test_settings_defaults_integrity.py
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

675 lines
25 KiB
Python

"""
Tests for settings defaults integrity.
Phase 1 of the JSON → Python settings migration. These tests validate the
current JSON-based default settings and will serve as a safety net when
settings definitions are migrated to Python modules in Phase 2.
The tests perform structural validation, cross-referencing against Python
enums, and static analysis of consumed setting keys vs. defined defaults.
"""
import json
import os
import re
from pathlib import Path
import pytest
from local_deep_research.settings.manager import SettingsManager
from local_deep_research.utilities.enums import KnowledgeAccumulationApproach
# ---------------------------------------------------------------------------
# Registry: enums tied to settings
# ---------------------------------------------------------------------------
ENUM_SETTINGS = {
"general.knowledge_accumulation": (
KnowledgeAccumulationApproach,
# Active values only — MAX_NR_OF_CHARACTERS is dead code (Phase 4)
{"ITERATION", "QUESTION", "NO_KNOWLEDGE"},
),
}
# ---------------------------------------------------------------------------
# Known-unused settings (exist in defaults but not consumed by Python code).
# Some are JS-frontend-only; others are truly dead. Phase 4 will remove
# dead ones and document frontend-only ones.
# ---------------------------------------------------------------------------
KNOWN_UNUSED = {
# Truly unused / dead code (candidates for future removal)
"report.export_formats",
"research_library.confirm_deletions",
"search.quality_check_urls",
# Only ever consumed by the iterdrag/standard strategies, which were
# removed when the experimental strategies were dropped. The setting and
# its KnowledgeAccumulationApproach enum are now dead; left in defaults
# for the planned Phase 4 settings cleanup.
"general.knowledge_accumulation",
"general.knowledge_accumulation_context_limit",
}
# ---------------------------------------------------------------------------
# Known select-option data bugs in current JSON (to be fixed in Phase 2/3).
# These are real bugs caught by test_select_options_validity — we skip them
# so the test passes on current JSON while still catching NEW regressions.
# ---------------------------------------------------------------------------
KNOWN_SELECT_ISSUES = {
# 'dark' was a legacy theme name; dynamic options from theme_registry
# don't include it (should be e.g. 'midnight' or 'system')
"app.theme",
# Options list is suggestive (common models), not restrictive;
# user can type any model name. Default is empty so users must
# consciously pick a model.
"llm.model",
# Default '265' is a typo — should be '365'.
"search.journal_reputation.reanalysis_period",
}
# ---------------------------------------------------------------------------
# Known numeric constraint data bugs in current JSON.
# ---------------------------------------------------------------------------
KNOWN_NUMERIC_ISSUES = set()
# ---------------------------------------------------------------------------
# Known snapshot/get_setting divergences.
# These are structural issues where get_setting() expands engine parent keys
# or deserializes JSON list values differently from the snapshot path.
# ---------------------------------------------------------------------------
KNOWN_SNAPSHOT_DIVERGENCES = {
# Engine parent keys: get_setting() expands to include all children,
# snapshot stores the raw dict value
"search.engine.web.gutenberg",
"search.engine.web.nasa_ads",
"search.engine.web.openalex",
"search.engine.web.openlibrary",
"search.engine.web.pubchem",
"search.engine.web.semantic_scholar",
"search.engine.web.stackexchange",
"search.engine.web.zenodo",
}
# ---------------------------------------------------------------------------
# Consumed settings that intentionally have no JSON default.
# They use explicit defaults in code, are env-only, or are runtime-created.
# ---------------------------------------------------------------------------
KNOWN_MISSING_DEFAULTS = {
# Env-only bootstrap settings
"bootstrap.data_dir",
# Runtime/programmatic settings (created by SettingsManager, not JSON)
"app.version",
"document_scheduler.last_run",
# Consumed with explicit code defaults, no JSON entry needed
"app.api_rate_limit",
"app.default_theme",
"app.enable_api",
"llm.max_retries",
"llm.request_timeout",
"llm.streaming",
"llm.openai.api_base",
"llm.openai.organization",
# Embeddings settings (consumed by embeddings config, sparse JSON coverage)
"embeddings.ollama.model",
"embeddings.openai.api_key",
"embeddings.openai.base_url",
"embeddings.openai.dimensions",
"embeddings.openai.model",
"embeddings.provider",
"embeddings.sentence_transformers.device",
"embeddings.sentence_transformers.model",
# Notification settings consumed but not in defaults JSON
"notifications.allow_private_ips",
# Search settings consumed with code defaults
"search.questions",
}
# ---------------------------------------------------------------------------
# Required schema fields for every default setting
# ---------------------------------------------------------------------------
REQUIRED_FIELDS = {
"description": str,
"editable": bool,
"name": str,
"ui_element": str,
"visible": bool,
"type": str,
# "value" can be any type, checked separately
}
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
GOLDEN_MASTER_PATH = Path(__file__).parent / "golden_master_settings.json"
SRC_DIR = Path(__file__).parent.parent.parent / "src" / "local_deep_research"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def load_all_defaults() -> dict:
"""Load defaults via SettingsManager with no DB."""
manager = SettingsManager(db_session=None)
return manager.default_settings
def collect_consumed_setting_keys() -> set:
"""Static analysis: regex-find all setting keys in Python code under src/.
Covers get_setting(), get_setting_from_snapshot(), get_bool_setting(),
extract_setting_value(), extract_bool_setting(),
the deprecated db_main_thread wrapper, and dict-key access patterns
used in web routes and search engine factory.
"""
# The deprecated function name is split across concatenation to avoid
# triggering the pre-commit hook that flags its usage.
_deprecated_fn = "get_setting_from_db" + "_main_thread"
patterns = [
r'get_setting\(\s*["\']([^"\']+)["\']',
# Matches both _get_setting(snapshot, "key") and the egress
# policy module's _get_setting_value(snapshot, "key", default).
r'_get_setting(?:_value)?\([^,]+,\s*["\']([^"\']+)["\']',
r'get_setting_from_snapshot\(\s*["\']([^"\']+)["\']',
r'get_bool_setting\(\s*["\']([^"\']+)["\']',
r'get_bool_setting_from_snapshot\(\s*["\']([^"\']+)["\']',
r'extract_setting_value\([^,]+,\s*["\']([^"\']+)["\']',
r'extract_bool_setting\([^,]+,\s*["\']([^"\']+)["\']',
rf'{_deprecated_fn}\(\s*["\']([^"\']+)["\']',
# Dict key presence check: "key" in settings_snapshot
r'"([a-z][a-z0-9_.]+)"\s+in\s+settings_snapshot',
# Web route key comparison: setting.key == "key"
r'setting\.key\s*==\s*"([^"]+)"',
# Provider-class api_key_setting declarations (consumed indirectly
# via cls.resolve_api_key / cls.has_api_key on BaseLLMProvider
# rather than a literal get_setting_from_snapshot call).
r'api_key_setting\s*=\s*"([^"]+)"',
]
compiled = [re.compile(p) for p in patterns]
keys = set()
for py_file in SRC_DIR.rglob("*.py"):
try:
text = py_file.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for pattern in compiled:
keys.update(pattern.findall(text))
return keys
def _is_dynamic_setting(key: str) -> bool:
"""Keys consumed dynamically, not via explicit get_setting() calls.
These are batch-loaded via parent prefix (e.g., get_setting("search.engine.web")
returns all children), consumed by LLM/embeddings provider factories that
construct keys from the provider name, or consumed by web frontend JS.
"""
dynamic_prefixes = (
# Search engine configs: batch-loaded by search_engines_config.py
"search.engine.",
# LLM provider-specific settings: key constructed from provider name
# e.g., f"llm.{provider}.api_key" in llm_config.py
"llm.google.",
"llm.ionos.",
"llm.openrouter.",
"llm.xai.",
"llm.ollama.",
"llm.lmstudio.",
"llm.openai_endpoint.",
"llm.anthropic_endpoint.",
"llm.deepseek.",
# News subsystem: many settings consumed by JS frontend or
# batch-loaded by news scheduler
"news.",
# Notification event toggles: consumed by JS frontend
"notifications.",
# Rate limiting config: batch-loaded by rate limiter
"rate_limiting.",
# Security settings: consumed by web routes / server config
"security.",
# UI warning dismissals: consumed by JS frontend
"app.warnings.",
)
return any(key.startswith(p) for p in dynamic_prefixes)
def _is_metadata_setting(key: str) -> bool:
"""Keys that are engine/provider metadata, not consumed via get_setting()."""
metadata_suffixes = (
".display_name",
".module_path",
".class_name",
".strengths",
".weaknesses",
)
metadata_prefixes = (
"search.engines.",
"focused_iteration.",
)
return any(key.endswith(s) for s in metadata_suffixes) or any(
key.startswith(p) for p in metadata_prefixes
)
def _is_web_consumed_setting(key: str) -> bool:
"""Settings consumed by web server/app factory, not via get_setting().
These are read via env var access (load_server_config), app_factory
configuration, or auth route logic — patterns not caught by get_setting()
regex.
"""
return key in {
"app.allow_registrations",
"app.debug",
"app.enable_file_logging",
"app.enable_notifications",
"app.enable_web",
"app.max_global_concurrent_researches",
"app.theme",
"app.web_interface",
}
def _extract_option_values(options: list) -> list:
"""Extract comparable values from an options list.
Handles both string options and {"label": ..., "value": ...} dicts.
"""
values = []
for opt in options:
if isinstance(opt, dict):
values.append(opt.get("value", opt.get("label")))
else:
values.append(opt)
return values
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestSettingsDefaultsIntegrity:
"""Validates structural integrity of all default settings."""
@pytest.fixture(autouse=True)
def clean_env(self):
"""Save/clear/restore LDR_* env vars to prevent pollution across tests."""
original_env = {
k: v for k, v in os.environ.items() if k.startswith("LDR_")
}
for key in list(os.environ.keys()):
if key.startswith("LDR_"):
os.environ.pop(key, None)
yield
for key in list(os.environ.keys()):
if key.startswith("LDR_"):
os.environ.pop(key, None)
for key, value in original_env.items():
os.environ[key] = value
@pytest.fixture(scope="class")
def defaults(self) -> dict:
return load_all_defaults()
# -- Test 1: Schema validation ------------------------------------------
def test_schema_validation(self, defaults):
"""Every default setting must have required fields with correct types."""
errors = []
for key, setting in defaults.items():
# "value" must exist (can be any type including None)
if "value" not in setting:
errors.append(f"{key}: missing 'value' field")
for field, expected_type in REQUIRED_FIELDS.items():
if field not in setting:
errors.append(f"{key}: missing '{field}' field")
elif not isinstance(setting[field], expected_type):
errors.append(
f"{key}: '{field}' should be {expected_type.__name__}, "
f"got {type(setting[field]).__name__}"
)
assert not errors, f"{len(errors)} schema violation(s):\n" + "\n".join(
errors
)
# -- Test 2: Settings manager loads all defaults ------------------------
def test_settings_manager_loads_all_defaults(self, defaults):
"""SettingsManager(db_session=None) must return a reasonable number of settings."""
assert len(defaults) >= 400, (
f"Expected at least 400 settings, got {len(defaults)}. "
f"Some JSON files may have failed to load."
)
# -- Test 3: Value-type consistency ------------------------------------
def test_value_type_consistency(self, defaults):
"""Checkbox defaults must be bool, number/range defaults must be numeric."""
errors = []
for key, setting in defaults.items():
ui = setting.get("ui_element")
value = setting.get("value")
if value is None:
continue
if ui == "checkbox" and not isinstance(value, bool):
errors.append(
f"{key}: checkbox default should be bool, "
f"got {type(value).__name__} ({value!r})"
)
elif ui in ("number", "range") and not isinstance(
value, (int, float)
):
errors.append(
f"{key}: {ui} default should be numeric, "
f"got {type(value).__name__} ({value!r})"
)
assert not errors, f"{len(errors)} type mismatch(es):\n" + "\n".join(
errors
)
# -- Test 4: Select options validity -----------------------------------
def test_select_options_validity(self, defaults):
"""Settings with ui_element='select' must have valid options containing the default value."""
errors = []
for key, setting in defaults.items():
if setting.get("ui_element") != "select":
continue
if key in KNOWN_SELECT_ISSUES:
continue
options = setting.get("options")
if not options:
errors.append(f"{key}: select setting has no options")
continue
value = setting.get("value")
option_values = _extract_option_values(options)
if value not in option_values:
errors.append(
f"{key}: default value {value!r} not in options "
f"{option_values!r}"
)
assert not errors, (
f"{len(errors)} select option error(s):\n" + "\n".join(errors)
)
# -- Test 5: Enum-settings consistency ---------------------------------
def test_enum_settings_consistency(self, defaults):
"""Settings backed by Python enums must have options matching actual enum values."""
errors = []
for key, (enum_class, active_values) in ENUM_SETTINGS.items():
setting = defaults.get(key)
if setting is None:
errors.append(f"{key}: setting not found in defaults")
continue
# The setting should be a select with options matching the enum
options = setting.get("options")
if options is None:
# The current JSON has ui_element="text" with no options —
# this is WRONG: should be select with enum values as options
errors.append(
f"{key}: should be a 'select' with options matching "
f"{enum_class.__name__} enum values {active_values}, "
f"but has ui_element='{setting.get('ui_element')}' "
f"and no options"
)
continue
option_values = set(_extract_option_values(options))
if option_values != active_values:
errors.append(
f"{key}: options {option_values} don't match "
f"enum active values {active_values}"
)
# Default value must be a valid active enum member
value = setting.get("value")
if value not in active_values:
errors.append(
f"{key}: default value {value!r} not in "
f"active enum values {active_values}"
)
assert not errors, f"{len(errors)} enum mismatch(es):\n" + "\n".join(
errors
)
# -- Test 6: No orphaned settings --------------------------------------
def test_no_orphaned_settings(self, defaults):
"""Settings in defaults but not consumed by code should be accounted for."""
consumed = collect_consumed_setting_keys()
orphaned = []
for key in sorted(defaults.keys()):
if key in consumed:
continue
if key in KNOWN_UNUSED:
continue
if _is_dynamic_setting(key):
continue
if _is_metadata_setting(key):
continue
if _is_web_consumed_setting(key):
continue
orphaned.append(key)
assert not orphaned, (
f"{len(orphaned)} orphaned setting(s) (not consumed by code, "
f"not in KNOWN_UNUSED, not dynamic/metadata/web-consumed):\n"
+ "\n".join(f" {k}" for k in orphaned)
)
# -- Test 7: Consumed settings exist in defaults -----------------------
def test_consumed_settings_exist(self, defaults):
"""Every get_setting('key') call in code must have a matching default."""
consumed = collect_consumed_setting_keys()
default_keys = set(defaults.keys())
missing = []
for key in sorted(consumed):
if key in default_keys:
continue
if key in KNOWN_MISSING_DEFAULTS:
continue
# Skip keys that look like they're constructed dynamically
# (contain format-string markers or are parent-level prefix keys)
# Also skip keys without dots — all real settings use
# dot-separated namespacing (e.g., "app.debug").
if "{" in key or key.endswith(".") or "." not in key:
continue
# Skip parent-level keys used for batch loading
# (e.g., "search.engine.web" loads all search.engine.web.* children)
children = [dk for dk in default_keys if dk.startswith(f"{key}.")]
if children:
continue
# Skip keys that are children of a dynamic prefix
# (e.g., search.engine.web.guardian.api_key is under search.engine.web.*)
if _is_dynamic_setting(key):
continue
missing.append(key)
assert not missing, (
f"{len(missing)} consumed setting(s) missing from defaults:\n"
+ "\n".join(f" {k}" for k in missing)
)
# -- Test 8: Numeric constraints ---------------------------------------
def test_numeric_constraints(self, defaults):
"""Settings with min_value/max_value must have defaults within range."""
errors = []
for key, setting in defaults.items():
if key in KNOWN_NUMERIC_ISSUES:
continue
value = setting.get("value")
min_val = setting.get("min_value")
max_val = setting.get("max_value")
if not isinstance(value, (int, float)):
continue
if min_val is not None and value < min_val:
errors.append(f"{key}: default {value} < min_value {min_val}")
if max_val is not None and value > max_val:
errors.append(f"{key}: default {value} > max_value {max_val}")
assert not errors, f"{len(errors)} range violation(s):\n" + "\n".join(
errors
)
# -- Test 9: Settings snapshot -----------------------------------------
def test_settings_snapshot(self, defaults):
"""All default setting keys should be present in a snapshot built from defaults."""
manager = SettingsManager(db_session=None)
snapshot = manager.get_settings_snapshot()
missing = set(defaults.keys()) - set(snapshot.keys())
assert not missing, (
f"{len(missing)} key(s) missing from snapshot:\n"
+ "\n".join(f" {k}" for k in sorted(missing))
)
# -- Test 10: Golden master --------------------------------------------
def test_golden_master(self, defaults):
"""Serialize defaults to JSON; compare against golden master on subsequent runs.
First run creates the golden master file. This test ensures the
Phase 2 Python modules produce identical output to the JSON files.
"""
# Build a serializable representation (sorted for determinism)
current = {}
for key in sorted(defaults.keys()):
setting = dict(defaults[key])
# Normalize: drop any runtime-injected fields that vary
# (theme options are dynamically injected from theme_registry)
current[key] = setting
current_json = (
json.dumps(
current,
indent=2,
sort_keys=True,
default=str,
ensure_ascii=False,
)
+ "\n"
)
if not GOLDEN_MASTER_PATH.exists():
GOLDEN_MASTER_PATH.write_text(current_json, encoding="utf-8")
pytest.skip(
f"Golden master created at {GOLDEN_MASTER_PATH}. "
f"Run tests again to validate."
)
expected = GOLDEN_MASTER_PATH.read_text(encoding="utf-8")
if current_json != expected:
# Find differences for a helpful error message
current_data = json.loads(current_json)
expected_data = json.loads(expected)
added = set(current_data.keys()) - set(expected_data.keys())
removed = set(expected_data.keys()) - set(current_data.keys())
changed = {
k
for k in current_data
if k in expected_data and current_data[k] != expected_data[k]
}
diff_parts = []
if added:
diff_parts.append(f"Added keys: {sorted(added)}")
if removed:
diff_parts.append(f"Removed keys: {sorted(removed)}")
if changed:
diff_parts.append(f"Changed keys: {sorted(changed)}")
diff_msg = (
"\n".join(diff_parts) if diff_parts else "Content differs"
)
pytest.fail(
f"Settings defaults have changed from golden master.\n"
f"{diff_msg}\n"
f"If this is intentional, delete {GOLDEN_MASTER_PATH} "
f"and re-run."
)
# -- Test 11: Reverse value-type consistency ----------------------------
def test_value_type_implies_ui_element(self, defaults):
"""Bool values must use checkbox; numeric values must not use text-like elements."""
errors = []
for key, setting in defaults.items():
ui = setting.get("ui_element")
value = setting.get("value")
if value is None:
continue
if isinstance(value, bool) and ui != "checkbox":
errors.append(
f"{key}: bool value {value!r} has ui_element='{ui}', "
f"should be 'checkbox'"
)
elif (
isinstance(value, (int, float))
and not isinstance(value, bool)
and ui in ("text", "password", "textarea")
):
errors.append(
f"{key}: numeric value {value!r} has ui_element='{ui}', "
f"consider 'number' or 'range'"
)
assert not errors, (
f"{len(errors)} reverse type mismatch(es):\n" + "\n".join(errors)
)
# -- Test 12: Snapshot values match typed get_setting -------------------
def test_snapshot_values_match_get_setting(self, defaults):
"""Snapshot values must equal get_setting() typed values for every key."""
manager = SettingsManager(db_session=None)
snapshot = manager.get_settings_snapshot()
errors = []
for key in defaults:
if key in KNOWN_SNAPSHOT_DIVERGENCES:
continue
snapshot_val = snapshot.get(key)
typed_val = manager.get_setting(key)
if snapshot_val != typed_val:
errors.append(
f"{key}: snapshot={snapshot_val!r} ({type(snapshot_val).__name__}) "
f"vs get_setting={typed_val!r} ({type(typed_val).__name__})"
)
assert not errors, (
f"{len(errors)} snapshot/get_setting divergence(s):\n"
+ "\n".join(errors)
)