diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 00000000..2b989001 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,894 @@ +"""Unit tests for src/config.py — Settings, validation, and helper functions. + +Refs #1172 +""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +pytestmark = pytest.mark.unit + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _make_settings(**env_overrides): + """Create a fresh Settings instance with isolated env vars.""" + from config import Settings + + # Strip keys that might bleed in from the test environment + clean_env = { + k: v + for k, v in os.environ.items() + if not k.startswith( + ( + "OLLAMA_", + "TIMMY_", + "AGENT_", + "DEBUG", + "GITEA_", + "GROK_", + "ANTHROPIC_", + "SPARK_", + "MEMORY_", + "MAX_", + "DISCORD_", + "TELEGRAM_", + "CORS_", + "TRUSTED_", + "L402_", + "LIGHTNING_", + "REPO_ROOT", + "RQLITE_", + "BRAIN_", + "SELF_MODIFY", + "WORK_ORDERS", + "VASSAL_", + "PAPERCLIP_", + "OPENFANG_", + "HERMES_", + "BACKLOG_", + "LOOP_QA", + "FOCUS_", + "THINKING_", + "HANDS_", + "WEEKLY_", + "AUTORESEARCH_", + "REWARD_", + "BROWSER_", + "GABS_", + "SCRIPTURE_", + "MCP_", + "CHAT_API", + "CSRF_", + "ERROR_", + "DB_", + "MODERATION_", + "SOVEREIGNTY_", + "XAI_", + "CLAUDE_", + "FLUX_", + "IMAGE_", + "MUSIC_", + "VIDEO_", + "CREATIVE_", + "WAN_", + "ACE_", + "GIT_", + ) + ) + } + clean_env.update(env_overrides) + with patch.dict(os.environ, clean_env, clear=True): + return Settings() + + +# ── normalize_ollama_url ────────────────────────────────────────────────────── + + +class TestNormalizeOllamaUrl: + """normalize_ollama_url replaces localhost with 127.0.0.1.""" + + def test_replaces_localhost(self): + from config import normalize_ollama_url + + assert normalize_ollama_url("http://localhost:11434") == "http://127.0.0.1:11434" + + def test_preserves_ip_address(self): + from config import normalize_ollama_url + + assert normalize_ollama_url("http://192.168.1.5:11434") == "http://192.168.1.5:11434" + + def test_preserves_non_localhost_hostname(self): + from config import normalize_ollama_url + + assert normalize_ollama_url("http://ollama.local:11434") == "http://ollama.local:11434" + + def test_replaces_multiple_occurrences(self): + from config import normalize_ollama_url + + result = normalize_ollama_url("http://localhost:11434/localhost") + assert result == "http://127.0.0.1:11434/127.0.0.1" + + def test_empty_string(self): + from config import normalize_ollama_url + + assert normalize_ollama_url("") == "" + + def test_127_0_0_1_unchanged(self): + from config import normalize_ollama_url + + url = "http://127.0.0.1:11434" + assert normalize_ollama_url(url) == url + + +# ── Settings defaults ───────────────────────────────────────────────────────── + + +class TestSettingsDefaults: + """Settings instantiation produces correct defaults.""" + + def test_default_agent_name(self): + s = _make_settings() + assert s.agent_name == "Agent" + + def test_default_ollama_url(self): + s = _make_settings() + assert s.ollama_url == "http://localhost:11434" + + def test_default_ollama_model(self): + s = _make_settings() + assert s.ollama_model == "qwen3:14b" + + def test_default_ollama_fast_model(self): + s = _make_settings() + assert s.ollama_fast_model == "qwen3:8b" + + def test_default_ollama_num_ctx(self): + s = _make_settings() + assert s.ollama_num_ctx == 32768 + + def test_default_ollama_max_loaded_models(self): + s = _make_settings() + assert s.ollama_max_loaded_models == 2 + + def test_default_debug_false(self): + s = _make_settings() + assert s.debug is False + + def test_default_timmy_env(self): + s = _make_settings() + assert s.timmy_env == "development" + + def test_default_timmy_test_mode_false(self): + s = _make_settings() + assert s.timmy_test_mode is False + + def test_default_spark_enabled(self): + s = _make_settings() + assert s.spark_enabled is True + + def test_default_lightning_backend(self): + s = _make_settings() + assert s.lightning_backend == "mock" + + def test_default_max_agent_steps(self): + s = _make_settings() + assert s.max_agent_steps == 10 + + def test_default_memory_prune_days(self): + s = _make_settings() + assert s.memory_prune_days == 90 + + def test_default_memory_prune_keep_facts(self): + s = _make_settings() + assert s.memory_prune_keep_facts is True + + def test_default_fallback_models_is_list(self): + s = _make_settings() + assert isinstance(s.fallback_models, list) + assert len(s.fallback_models) > 0 + + def test_default_vision_fallback_models_is_list(self): + s = _make_settings() + assert isinstance(s.vision_fallback_models, list) + assert len(s.vision_fallback_models) > 0 + + def test_default_cors_origins_is_list(self): + s = _make_settings() + assert isinstance(s.cors_origins, list) + assert len(s.cors_origins) > 0 + + def test_default_trusted_hosts_is_list(self): + s = _make_settings() + assert isinstance(s.trusted_hosts, list) + assert "localhost" in s.trusted_hosts + + def test_default_timmy_model_backend(self): + s = _make_settings() + assert s.timmy_model_backend == "ollama" + + def test_default_grok_enabled_false(self): + s = _make_settings() + assert s.grok_enabled is False + + def test_default_moderation_enabled(self): + s = _make_settings() + assert s.moderation_enabled is True + + def test_default_moderation_threshold(self): + s = _make_settings() + assert s.moderation_threshold == 0.8 + + def test_default_telemetry_disabled(self): + s = _make_settings() + assert s.telemetry_enabled is False + + def test_default_db_busy_timeout(self): + s = _make_settings() + assert s.db_busy_timeout_ms == 5000 + + def test_default_chat_api_max_body_bytes(self): + s = _make_settings() + assert s.chat_api_max_body_bytes == 1_048_576 + + def test_default_csrf_cookie_secure_false(self): + s = _make_settings() + assert s.csrf_cookie_secure is False + + def test_default_self_modify_disabled(self): + s = _make_settings() + assert s.self_modify_enabled is False + + def test_default_vassal_disabled(self): + s = _make_settings() + assert s.vassal_enabled is False + + def test_default_focus_mode(self): + s = _make_settings() + assert s.focus_mode == "broad" + + def test_default_thinking_enabled(self): + s = _make_settings() + assert s.thinking_enabled is True + + def test_default_gitea_url(self): + s = _make_settings() + assert s.gitea_url == "http://localhost:3000" + + def test_default_hermes_enabled(self): + s = _make_settings() + assert s.hermes_enabled is True + + def test_default_scripture_enabled(self): + s = _make_settings() + assert s.scripture_enabled is True + + +# ── normalized_ollama_url property ─────────────────────────────────────────── + + +class TestNormalizedOllamaUrlProperty: + """normalized_ollama_url property applies normalize_ollama_url.""" + + def test_default_url_normalized(self): + s = _make_settings() + assert "127.0.0.1" in s.normalized_ollama_url + assert "localhost" not in s.normalized_ollama_url + + def test_custom_url_with_localhost(self): + s = _make_settings(OLLAMA_URL="http://localhost:9999") + assert s.normalized_ollama_url == "http://127.0.0.1:9999" + + def test_custom_url_without_localhost_unchanged(self): + s = _make_settings(OLLAMA_URL="http://192.168.1.5:11434") + assert s.normalized_ollama_url == "http://192.168.1.5:11434" + + +# ── Env var overrides ───────────────────────────────────────────────────────── + + +class TestSettingsEnvOverrides: + """Environment variables override default values.""" + + def test_agent_name_override(self): + s = _make_settings(AGENT_NAME="Timmy") + assert s.agent_name == "Timmy" + + def test_ollama_url_override(self): + s = _make_settings(OLLAMA_URL="http://10.0.0.1:11434") + assert s.ollama_url == "http://10.0.0.1:11434" + + def test_ollama_model_override(self): + s = _make_settings(OLLAMA_MODEL="llama3.1") + assert s.ollama_model == "llama3.1" + + def test_ollama_fast_model_override(self): + s = _make_settings(OLLAMA_FAST_MODEL="gemma:2b") + assert s.ollama_fast_model == "gemma:2b" + + def test_ollama_num_ctx_override(self): + s = _make_settings(OLLAMA_NUM_CTX="8192") + assert s.ollama_num_ctx == 8192 + + def test_debug_true_from_string(self): + s = _make_settings(DEBUG="true") + assert s.debug is True + + def test_debug_false_from_string(self): + s = _make_settings(DEBUG="false") + assert s.debug is False + + def test_timmy_env_production(self): + s = _make_settings(TIMMY_ENV="production") + assert s.timmy_env == "production" + + def test_timmy_test_mode_true(self): + s = _make_settings(TIMMY_TEST_MODE="true") + assert s.timmy_test_mode is True + + def test_grok_enabled_override(self): + s = _make_settings(GROK_ENABLED="true") + assert s.grok_enabled is True + + def test_spark_enabled_override(self): + s = _make_settings(SPARK_ENABLED="false") + assert s.spark_enabled is False + + def test_memory_prune_days_override(self): + s = _make_settings(MEMORY_PRUNE_DAYS="30") + assert s.memory_prune_days == 30 + + def test_max_agent_steps_override(self): + s = _make_settings(MAX_AGENT_STEPS="25") + assert s.max_agent_steps == 25 + + def test_telegram_token_override(self): + s = _make_settings(TELEGRAM_TOKEN="tg-secret") + assert s.telegram_token == "tg-secret" + + def test_discord_token_override(self): + s = _make_settings(DISCORD_TOKEN="dc-secret") + assert s.discord_token == "dc-secret" + + def test_gitea_url_override(self): + s = _make_settings(GITEA_URL="http://10.0.0.1:3000") + assert s.gitea_url == "http://10.0.0.1:3000" + + def test_gitea_repo_override(self): + s = _make_settings(GITEA_REPO="myorg/myrepo") + assert s.gitea_repo == "myorg/myrepo" + + def test_focus_mode_deep(self): + s = _make_settings(FOCUS_MODE="deep") + assert s.focus_mode == "deep" + + def test_thinking_interval_override(self): + s = _make_settings(THINKING_INTERVAL_SECONDS="60") + assert s.thinking_interval_seconds == 60 + + def test_hermes_interval_override(self): + s = _make_settings(HERMES_INTERVAL_SECONDS="60") + assert s.hermes_interval_seconds == 60 + + def test_vassal_enabled_override(self): + s = _make_settings(VASSAL_ENABLED="true") + assert s.vassal_enabled is True + + def test_self_modify_enabled_override(self): + s = _make_settings(SELF_MODIFY_ENABLED="true") + assert s.self_modify_enabled is True + + def test_moderation_enabled_override(self): + s = _make_settings(MODERATION_ENABLED="false") + assert s.moderation_enabled is False + + def test_l402_hmac_secret_override(self): + s = _make_settings(L402_HMAC_SECRET="mysecret") + assert s.l402_hmac_secret == "mysecret" + + def test_anthropic_api_key_override(self): + s = _make_settings(ANTHROPIC_API_KEY="sk-ant-abc") + assert s.anthropic_api_key == "sk-ant-abc" + + +# ── Type validation ─────────────────────────────────────────────────────────── + + +class TestSettingsTypeValidation: + """Pydantic correctly parses and validates types from string env vars.""" + + def test_bool_from_1(self): + s = _make_settings(DEBUG="1") + assert s.debug is True + + def test_bool_from_0(self): + s = _make_settings(DEBUG="0") + assert s.debug is False + + def test_int_field_rejects_non_numeric(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + _make_settings(OLLAMA_NUM_CTX="not_a_number") + + def test_timmy_env_rejects_invalid_literal(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + _make_settings(TIMMY_ENV="staging") + + def test_timmy_model_backend_rejects_invalid(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + _make_settings(TIMMY_MODEL_BACKEND="openai") + + def test_timmy_model_backend_accepts_all_valid_values(self): + for backend in ("ollama", "grok", "claude", "auto"): + s = _make_settings(TIMMY_MODEL_BACKEND=backend) + assert s.timmy_model_backend == backend + + def test_lightning_backend_accepts_mock(self): + s = _make_settings(LIGHTNING_BACKEND="mock") + assert s.lightning_backend == "mock" + + def test_lightning_backend_accepts_lnd(self): + s = _make_settings(LIGHTNING_BACKEND="lnd") + assert s.lightning_backend == "lnd" + + def test_lightning_backend_rejects_invalid(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + _make_settings(LIGHTNING_BACKEND="stripe") + + def test_focus_mode_rejects_invalid(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + _make_settings(FOCUS_MODE="zen") + + def test_extra_fields_ignored(self): + # model_config has extra="ignore" + s = _make_settings(TOTALLY_UNKNOWN_FIELD="hello") + assert not hasattr(s, "totally_unknown_field") + + def test_float_field_moderation_threshold(self): + s = _make_settings(MODERATION_THRESHOLD="0.95") + assert s.moderation_threshold == pytest.approx(0.95) + + def test_float_field_gabs_timeout(self): + s = _make_settings(GABS_TIMEOUT="10.5") + assert s.gabs_timeout == pytest.approx(10.5) + + +# ── Edge cases ──────────────────────────────────────────────────────────────── + + +class TestSettingsEdgeCases: + """Edge cases: empty strings, boundary values.""" + + def test_empty_string_tokens_stay_empty(self): + s = _make_settings(TELEGRAM_TOKEN="", DISCORD_TOKEN="") + assert s.telegram_token == "" + assert s.discord_token == "" + + def test_zero_int_fields(self): + s = _make_settings(OLLAMA_NUM_CTX="0", MEMORY_PRUNE_DAYS="0") + assert s.ollama_num_ctx == 0 + assert s.memory_prune_days == 0 + + def test_large_int_value(self): + s = _make_settings(CHAT_API_MAX_BODY_BYTES="104857600") + assert s.chat_api_max_body_bytes == 104857600 + + def test_negative_int_accepted(self): + # Pydantic doesn't constrain these to positive by default + s = _make_settings(MAX_AGENT_STEPS="-1") + assert s.max_agent_steps == -1 + + def test_empty_api_keys_are_strings(self): + s = _make_settings() + assert isinstance(s.anthropic_api_key, str) + assert isinstance(s.xai_api_key, str) + assert isinstance(s.gitea_token, str) + + +# ── _compute_repo_root ──────────────────────────────────────────────────────── + + +class TestComputeRepoRoot: + """_compute_repo_root auto-detects .git directory.""" + + def test_returns_non_empty_string(self): + from config import Settings + + s = Settings() + result = s._compute_repo_root() + assert isinstance(result, str) + assert len(result) > 0 + + def test_explicit_repo_root_returned_directly(self): + from config import Settings + + s = Settings() + s.repo_root = "/tmp/custom-repo" + assert s._compute_repo_root() == "/tmp/custom-repo" + + def test_detects_git_directory(self): + from config import Settings + + s = Settings() + result = s._compute_repo_root() + import os + + # The detected root should contain a .git directory (or be the cwd fallback) + assert os.path.isabs(result) + + +# ── model_post_init / gitea_token file fallback ─────────────────────────────── + + +class TestModelPostInit: + """model_post_init resolves gitea_token from file fallback.""" + + def test_gitea_token_from_env(self): + from config import Settings + + with patch.dict(os.environ, {"GITEA_TOKEN": "env-token-abc"}, clear=False): + s = Settings() + assert s.gitea_token == "env-token-abc" + + def test_gitea_token_stays_empty_when_no_file(self): + from config import Settings + + env = {k: v for k, v in os.environ.items() if k != "GITEA_TOKEN"} + with patch.dict(os.environ, env, clear=True): + with patch("os.path.isfile", return_value=False): + s = Settings() + assert s.gitea_token == "" + + def test_gitea_token_read_from_timmy_token_file(self, tmp_path): + """model_post_init reads token from .timmy_gitea_token file.""" + from config import Settings + + token_file = tmp_path / ".timmy_gitea_token" + token_file.write_text("file-token-xyz\n") + + env = {k: v for k, v in os.environ.items() if k != "GITEA_TOKEN"} + with patch.dict(os.environ, env, clear=True): + s = Settings() + # Override repo_root so post_init finds our temp file + original_compute = s._compute_repo_root + + def _fake_root(): + return str(tmp_path) + + s._compute_repo_root = _fake_root # type: ignore[method-assign] + # Re-run post_init logic manually since Settings is already created + s.gitea_token = "" + repo_root = _fake_root() + token_path = os.path.join(repo_root, ".timmy_gitea_token") + if os.path.isfile(token_path): + s.gitea_token = open(token_path).read().strip() # noqa: SIM115 + assert s.gitea_token == "file-token-xyz" + + def test_gitea_token_empty_file_stays_empty(self, tmp_path): + """Empty token file leaves gitea_token as empty string.""" + token_file = tmp_path / ".timmy_gitea_token" + token_file.write_text(" \n") # only whitespace + + from config import Settings + + env = {k: v for k, v in os.environ.items() if k != "GITEA_TOKEN"} + with patch.dict(os.environ, env, clear=True): + s = Settings() + # Simulate post_init with the tmp dir + s.gitea_token = "" + token_path = str(token_file) + if os.path.isfile(token_path): + token = open(token_path).read().strip() # noqa: SIM115 + if token: + s.gitea_token = token + assert s.gitea_token == "" + + +# ── check_ollama_model_available ────────────────────────────────────────────── + + +class TestCheckOllamaModelAvailable: + """check_ollama_model_available handles network responses and errors.""" + + def test_returns_false_on_oserror(self): + from config import check_ollama_model_available + + with patch("urllib.request.urlopen", side_effect=OSError("Connection refused")): + assert check_ollama_model_available("llama3.1") is False + + def test_returns_false_on_value_error(self): + from config import check_ollama_model_available + + with patch("urllib.request.urlopen", side_effect=ValueError("Bad JSON")): + assert check_ollama_model_available("llama3.1") is False + + def test_returns_true_exact_model_match(self): + import json + + from config import check_ollama_model_available + + response_data = json.dumps({"models": [{"name": "llama3.1:8b-instruct"}]}).encode() + mock_response = MagicMock() + mock_response.read.return_value = response_data + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response): + assert check_ollama_model_available("llama3.1") is True + + def test_returns_true_startswith_match(self): + import json + + from config import check_ollama_model_available + + response_data = json.dumps({"models": [{"name": "qwen3:14b"}]}).encode() + mock_response = MagicMock() + mock_response.read.return_value = response_data + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response): + # "qwen3" matches "qwen3:14b" via startswith + assert check_ollama_model_available("qwen3") is True + + def test_returns_false_when_model_not_found(self): + import json + + from config import check_ollama_model_available + + response_data = json.dumps({"models": [{"name": "qwen2.5:7b"}]}).encode() + mock_response = MagicMock() + mock_response.read.return_value = response_data + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response): + assert check_ollama_model_available("llama3.1") is False + + def test_returns_false_empty_model_list(self): + import json + + from config import check_ollama_model_available + + response_data = json.dumps({"models": []}).encode() + mock_response = MagicMock() + mock_response.read.return_value = response_data + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response): + assert check_ollama_model_available("llama3.1") is False + + def test_exact_name_match(self): + import json + + from config import check_ollama_model_available + + response_data = json.dumps({"models": [{"name": "qwen3:14b"}]}).encode() + mock_response = MagicMock() + mock_response.read.return_value = response_data + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response): + assert check_ollama_model_available("qwen3:14b") is True + + +# ── get_effective_ollama_model ──────────────────────────────────────────────── + + +class TestGetEffectiveOllamaModel: + """get_effective_ollama_model walks fallback chain.""" + + def test_returns_primary_when_available(self): + from config import get_effective_ollama_model + + with patch("config.check_ollama_model_available", return_value=True): + result = get_effective_ollama_model() + # Default is qwen3:14b + assert result == "qwen3:14b" + + def test_falls_back_when_primary_unavailable(self): + from config import get_effective_ollama_model, settings + + # Make primary unavailable, but one fallback available + primary = settings.ollama_model + fallback_target = settings.fallback_models[0] + + def side_effect(model): + return model == fallback_target + + with patch("config.check_ollama_model_available", side_effect=side_effect): + result = get_effective_ollama_model() + assert result == fallback_target + + def test_returns_user_model_when_nothing_available(self): + from config import get_effective_ollama_model, settings + + with patch("config.check_ollama_model_available", return_value=False): + result = get_effective_ollama_model() + # Last resort: returns user's configured model + assert result == settings.ollama_model + + def test_skips_unavailable_fallbacks(self): + from config import get_effective_ollama_model, settings + + # Only the last fallback is available + fallbacks = settings.fallback_models + last_fallback = fallbacks[-1] + + def side_effect(model): + return model == last_fallback + + with patch("config.check_ollama_model_available", side_effect=side_effect): + result = get_effective_ollama_model() + assert result == last_fallback + + +# ── validate_startup ────────────────────────────────────────────────────────── + + +class TestValidateStartup: + """validate_startup enforces security in production, warns in dev.""" + + def setup_method(self): + import config + + config._startup_validated = False + + def test_skips_in_test_mode(self): + import config + + with patch.dict(os.environ, {"TIMMY_TEST_MODE": "1"}): + config.validate_startup() + assert config._startup_validated is True + + def test_dev_mode_does_not_exit(self): + import config + + config._startup_validated = False + env = {k: v for k, v in os.environ.items() if k != "TIMMY_TEST_MODE"} + env["TIMMY_ENV"] = "development" + with patch.dict(os.environ, env, clear=True): + # Should not raise SystemExit + config.validate_startup() + assert config._startup_validated is True + + def test_production_exits_without_l402_hmac_secret(self): + import config + + config._startup_validated = False + with patch.object(config.settings, "timmy_env", "production"): + with patch.object(config.settings, "l402_hmac_secret", ""): + with patch.object(config.settings, "l402_macaroon_secret", ""): + with pytest.raises(SystemExit): + config.validate_startup(force=True) + + def test_production_exits_without_l402_macaroon_secret(self): + import config + + config._startup_validated = False + with patch.object(config.settings, "timmy_env", "production"): + with patch.object(config.settings, "l402_hmac_secret", "present"): + with patch.object(config.settings, "l402_macaroon_secret", ""): + with pytest.raises(SystemExit): + config.validate_startup(force=True) + + def test_production_exits_with_cors_wildcard(self): + import config + + config._startup_validated = False + with patch.object(config.settings, "timmy_env", "production"): + with patch.object(config.settings, "l402_hmac_secret", "secret1"): + with patch.object(config.settings, "l402_macaroon_secret", "secret2"): + with patch.object(config.settings, "cors_origins", ["*"]): + with pytest.raises(SystemExit): + config.validate_startup(force=True) + + def test_production_passes_with_all_secrets_and_no_wildcard(self): + import config + + config._startup_validated = False + with patch.object(config.settings, "timmy_env", "production"): + with patch.object(config.settings, "l402_hmac_secret", "secret1"): + with patch.object(config.settings, "l402_macaroon_secret", "secret2"): + with patch.object( + config.settings, "cors_origins", ["http://localhost:3000"] + ): + config.validate_startup(force=True) + assert config._startup_validated is True + + def test_idempotent_without_force(self): + import config + + config._startup_validated = True + config.validate_startup() + assert config._startup_validated is True + + def test_force_reruns_when_already_validated(self): + import config + + config._startup_validated = True + with patch.dict(os.environ, {"TIMMY_TEST_MODE": "1"}): + config.validate_startup(force=True) + # Should have run (and set validated again) + assert config._startup_validated is True + + def test_dev_warns_on_cors_wildcard(self, caplog): + import logging + + import config + + config._startup_validated = False + env = {k: v for k, v in os.environ.items() if k != "TIMMY_TEST_MODE"} + env["TIMMY_ENV"] = "development" + with patch.dict(os.environ, env, clear=True): + with patch.object(config.settings, "timmy_env", "development"): + with patch.object(config.settings, "cors_origins", ["*"]): + with patch.object(config.settings, "l402_hmac_secret", ""): + with patch.object(config.settings, "l402_macaroon_secret", ""): + with caplog.at_level(logging.WARNING, logger="config"): + config.validate_startup(force=True) + assert any("CORS" in rec.message for rec in caplog.records) + + +# ── APP_START_TIME ──────────────────────────────────────────────────────────── + + +class TestAppStartTime: + """APP_START_TIME is set at module load.""" + + def test_app_start_time_is_datetime(self): + from datetime import datetime + + from config import APP_START_TIME + + assert isinstance(APP_START_TIME, datetime) + + def test_app_start_time_has_utc_timezone(self): + from config import APP_START_TIME + + assert APP_START_TIME.tzinfo is not None + + def test_app_start_time_is_in_the_past_or_now(self): + from datetime import UTC, datetime + + from config import APP_START_TIME + + assert APP_START_TIME <= datetime.now(UTC) + + +# ── Module-level singleton ──────────────────────────────────────────────────── + + +class TestSettingsSingleton: + """The module-level `settings` singleton is a Settings instance.""" + + def test_settings_is_settings_instance(self): + from config import Settings, settings + + assert isinstance(settings, Settings) + + def test_settings_repo_root_is_set(self): + from config import settings + + assert isinstance(settings.repo_root, str) + + def test_settings_has_expected_defaults(self): + from config import settings + + # In test mode these may be overridden, but type should be correct + assert isinstance(settings.ollama_url, str) + assert isinstance(settings.debug, bool)