diff --git a/tests/test_config_module.py b/tests/test_config_module.py new file mode 100644 index 00000000..2941a2f1 --- /dev/null +++ b/tests/test_config_module.py @@ -0,0 +1,470 @@ +"""Tests for src/config.py — Settings, validation, and helper functions.""" + +import os +from unittest.mock import patch + +import pytest + + +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(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" + + +class TestSettingsDefaults: + """Settings instantiation produces correct defaults.""" + + def _make_settings(self, **env_overrides): + """Create a fresh Settings instance with given env overrides.""" + from config import Settings + + clean_env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("OLLAMA_", "TIMMY_", "AGENT_", "DEBUG")) + } + clean_env.update(env_overrides) + with patch.dict(os.environ, clean_env, clear=True): + return Settings() + + def test_default_agent_name(self): + s = self._make_settings() + assert s.agent_name == "Agent" + + def test_default_ollama_url(self): + s = self._make_settings() + assert s.ollama_url == "http://localhost:11434" + + def test_default_ollama_model(self): + s = self._make_settings() + assert s.ollama_model == "qwen3:30b" + + def test_default_ollama_num_ctx(self): + s = self._make_settings() + assert s.ollama_num_ctx == 4096 + + def test_default_debug_false(self): + s = self._make_settings() + assert s.debug is False + + def test_default_timmy_env(self): + s = self._make_settings() + assert s.timmy_env == "development" + + def test_default_timmy_test_mode(self): + s = self._make_settings() + assert s.timmy_test_mode is False + + def test_default_spark_enabled(self): + s = self._make_settings() + assert s.spark_enabled is True + + def test_default_lightning_backend(self): + s = self._make_settings() + assert s.lightning_backend == "mock" + + def test_default_max_agent_steps(self): + s = self._make_settings() + assert s.max_agent_steps == 10 + + def test_default_memory_prune_days(self): + s = self._make_settings() + assert s.memory_prune_days == 90 + + def test_default_fallback_models_is_list(self): + s = self._make_settings() + assert isinstance(s.fallback_models, list) + assert len(s.fallback_models) > 0 + + def test_default_cors_origins_is_list(self): + s = self._make_settings() + assert isinstance(s.cors_origins, list) + + def test_default_trusted_hosts_is_list(self): + s = self._make_settings() + assert isinstance(s.trusted_hosts, list) + assert "localhost" in s.trusted_hosts + + def test_normalized_ollama_url_property(self): + s = self._make_settings() + assert "127.0.0.1" in s.normalized_ollama_url + assert "localhost" not in s.normalized_ollama_url + + +class TestSettingsEnvOverrides: + """Environment variables override default values.""" + + def _make_settings(self, **env_overrides): + from config import Settings + + clean_env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("OLLAMA_", "TIMMY_", "AGENT_", "DEBUG")) + } + clean_env.update(env_overrides) + with patch.dict(os.environ, clean_env, clear=True): + return Settings() + + def test_agent_name_override(self): + s = self._make_settings(AGENT_NAME="Timmy") + assert s.agent_name == "Timmy" + + def test_ollama_url_override(self): + s = self._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 = self._make_settings(OLLAMA_MODEL="llama3.1") + assert s.ollama_model == "llama3.1" + + def test_debug_true_from_string(self): + s = self._make_settings(DEBUG="true") + assert s.debug is True + + def test_debug_false_from_string(self): + s = self._make_settings(DEBUG="false") + assert s.debug is False + + def test_numeric_override(self): + s = self._make_settings(OLLAMA_NUM_CTX="8192") + assert s.ollama_num_ctx == 8192 + + def test_max_agent_steps_override(self): + s = self._make_settings(MAX_AGENT_STEPS="25") + assert s.max_agent_steps == 25 + + def test_timmy_env_production(self): + s = self._make_settings(TIMMY_ENV="production") + assert s.timmy_env == "production" + + def test_timmy_test_mode_true(self): + s = self._make_settings(TIMMY_TEST_MODE="true") + assert s.timmy_test_mode is True + + def test_grok_enabled_override(self): + s = self._make_settings(GROK_ENABLED="true") + assert s.grok_enabled is True + + def test_spark_enabled_override(self): + s = self._make_settings(SPARK_ENABLED="false") + assert s.spark_enabled is False + + def test_memory_prune_days_override(self): + s = self._make_settings(MEMORY_PRUNE_DAYS="30") + assert s.memory_prune_days == 30 + + +class TestSettingsTypeValidation: + """Pydantic correctly parses and validates types from string env vars.""" + + def _make_settings(self, **env_overrides): + from config import Settings + + clean_env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("OLLAMA_", "TIMMY_", "AGENT_", "DEBUG")) + } + clean_env.update(env_overrides) + with patch.dict(os.environ, clean_env, clear=True): + return Settings() + + def test_bool_from_1(self): + s = self._make_settings(DEBUG="1") + assert s.debug is True + + def test_bool_from_0(self): + s = self._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): + self._make_settings(OLLAMA_NUM_CTX="not_a_number") + + def test_literal_field_rejects_invalid(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + self._make_settings(TIMMY_ENV="staging") + + def test_literal_backend_rejects_invalid(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + self._make_settings(TIMMY_MODEL_BACKEND="openai") + + def test_literal_backend_accepts_valid(self): + for backend in ("ollama", "grok", "claude", "auto"): + s = self._make_settings(TIMMY_MODEL_BACKEND=backend) + assert s.timmy_model_backend == backend + + def test_extra_fields_ignored(self): + # model_config has extra="ignore" + s = self._make_settings(TOTALLY_UNKNOWN_FIELD="hello") + assert not hasattr(s, "totally_unknown_field") + + +class TestSettingsEdgeCases: + """Edge cases: empty strings, missing vars, boundary values.""" + + def _make_settings(self, **env_overrides): + from config import Settings + + clean_env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("OLLAMA_", "TIMMY_", "AGENT_", "DEBUG")) + } + clean_env.update(env_overrides) + with patch.dict(os.environ, clean_env, clear=True): + return Settings() + + def test_empty_string_tokens_stay_empty(self): + s = self._make_settings(TELEGRAM_TOKEN="", DISCORD_TOKEN="") + assert s.telegram_token == "" + assert s.discord_token == "" + + def test_zero_int_fields(self): + s = self._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 = self._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 + s = self._make_settings(MAX_AGENT_STEPS="-1") + assert s.max_agent_steps == -1 + + +class TestComputeRepoRoot: + """_compute_repo_root auto-detects .git directory.""" + + def test_returns_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_used(self): + from config import Settings + + with patch.dict(os.environ, {"REPO_ROOT": "/tmp/myrepo"}, clear=False): + s = Settings() + s.repo_root = "/tmp/myrepo" + assert s._compute_repo_root() == "/tmp/myrepo" + + +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": "test-token-123"}, clear=False): + s = Settings() + assert s.gitea_token == "test-token-123" + + 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 == "" + + +class TestCheckOllamaModelAvailable: + """check_ollama_model_available handles network responses and errors.""" + + def test_returns_false_on_network_error(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_true_when_model_found(self): + import json + from unittest.mock import MagicMock + + 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_false_when_model_not_found(self): + import json + from unittest.mock import MagicMock + + 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 + + +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() + assert result == "qwen3:30b" + + def test_falls_back_when_primary_unavailable(self): + from config import get_effective_ollama_model + + def side_effect(model): + return model == "llama3.1:8b-instruct" + + with patch("config.check_ollama_model_available", side_effect=side_effect): + result = get_effective_ollama_model() + assert result == "llama3.1:8b-instruct" + + def test_returns_user_model_when_nothing_available(self): + from config import get_effective_ollama_model + + with patch("config.check_ollama_model_available", return_value=False): + result = get_effective_ollama_model() + assert result == "qwen3:30b" + + +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_warns_but_does_not_exit(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 caplog.at_level(logging.WARNING, logger="config"): + config.validate_startup() + assert config._startup_validated is True + + def test_production_exits_without_secrets(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"] = "production" + env.pop("L402_HMAC_SECRET", None) + env.pop("L402_MACAROON_SECRET", None) + with patch.dict(os.environ, env, clear=True): + 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_with_cors_wildcard(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"] = "production" + with patch.dict(os.environ, env, clear=True): + 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(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"] = "production" + with patch.dict(os.environ, env, clear=True): + 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 + # Should return immediately without doing anything + config.validate_startup() + assert config._startup_validated is True + + +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_timezone(self): + from config import APP_START_TIME + + assert APP_START_TIME.tzinfo is not None