"""Unit tests for src/my_deepagent/config.py.""" from __future__ import annotations from pathlib import Path import pytest from pydantic import ValidationError from my_deepagent.config import Config, load_config # --------------------------------------------------------------------------- # Default values (no env, no file) # --------------------------------------------------------------------------- def test_default_log_level(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = Config() assert cfg.log_level == "info" def test_default_lang(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = Config() assert cfg.lang == "ko" def test_default_budget_daily_usd(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = Config() assert cfg.budget_daily_usd == pytest.approx(5.0) def test_default_budget_run_usd(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = Config() assert cfg.budget_run_usd == pytest.approx(1.0) def test_default_budget_on_hit(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = Config() assert cfg.budget_on_hit == "prompt" def test_default_persona(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = Config() assert cfg.default_persona == "default-interactive" def test_default_openrouter_api_key_is_none(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) # _env_file=None bypasses any .env that may exist in the cwd (e.g. dev keys). cfg = Config(_env_file=None) assert cfg.openrouter_api_key is None # --------------------------------------------------------------------------- # Env var overrides # --------------------------------------------------------------------------- def test_env_budget_daily_usd(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_BUDGET_DAILY_USD", "10") cfg = Config() assert cfg.budget_daily_usd == pytest.approx(10.0) def test_env_lang_en(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_LANG", "en") cfg = Config() assert cfg.lang == "en" def test_env_log_level_debug(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_LOG_LEVEL", "debug") cfg = Config() assert cfg.log_level == "debug" def test_env_openrouter_api_key(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_OPENROUTER_API_KEY", "sk-test-abc") cfg = Config() assert cfg.openrouter_api_key == "sk-test-abc" def test_env_langsmith_tracing(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_LANGSMITH_TRACING", "true") cfg = Config() assert cfg.langsmith_tracing is True # --------------------------------------------------------------------------- # Validation errors for invalid values # --------------------------------------------------------------------------- def test_invalid_lang_raises(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_LANG", "fr") with pytest.raises(ValidationError): Config() def test_invalid_log_level_raises(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_LOG_LEVEL", "verbose") with pytest.raises(ValidationError): Config() def test_invalid_budget_on_hit_raises(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_BUDGET_ON_HIT", "explode") with pytest.raises(ValidationError): Config() def test_negative_budget_raises(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) with pytest.raises(ValidationError): Config(budget_daily_usd=-1.0) # --------------------------------------------------------------------------- # Frozen check # --------------------------------------------------------------------------- def test_frozen_prevents_mutation(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = Config() with pytest.raises((ValidationError, TypeError)): cfg.budget_daily_usd = 99 # type: ignore[misc] # --------------------------------------------------------------------------- # Path expansion (~ → absolute path) # --------------------------------------------------------------------------- def test_tilde_expansion_workspace_root(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_WORKSPACE_ROOT", "~/foo/bar") cfg = Config() assert cfg.workspace_root.is_absolute() assert "~" not in str(cfg.workspace_root) def test_tilde_expansion_data_dir(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) monkeypatch.setenv("MYDEEPAGENT_DATA_DIR", "~/mydata") cfg = Config() assert cfg.data_dir.is_absolute() # --------------------------------------------------------------------------- # TOML priority # --------------------------------------------------------------------------- def test_toml_overrides_default(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _clear_env(monkeypatch) toml_file = tmp_path / "config.toml" toml_file.write_text('lang = "en"\nbudget_daily_usd = 7.5\n') # Patch the toml_file location via init override # Config reads toml via SettingsConfigDict; we pass via class-level override trick: # Easiest approach: pass budget_daily_usd and lang directly to assert TOML *can* set them. # For true TOML path injection, subclass Config temporarily. class PatchedConfig(Config): model_config = Config.model_config.copy() PatchedConfig.model_config["toml_file"] = str(toml_file) cfg = PatchedConfig() assert cfg.lang == "en" assert cfg.budget_daily_usd == pytest.approx(7.5) # --------------------------------------------------------------------------- # load_config helper # --------------------------------------------------------------------------- def test_load_config_with_overrides(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = load_config(budget_daily_usd=20.0, lang="en") assert cfg.budget_daily_usd == pytest.approx(20.0) assert cfg.lang == "en" def test_load_config_default(monkeypatch: pytest.MonkeyPatch) -> None: _clear_env(monkeypatch) cfg = load_config() assert cfg.log_level == "info" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _ENV_KEYS = [ "MYDEEPAGENT_BUDGET_DAILY_USD", "MYDEEPAGENT_BUDGET_DAILY_WARN_USD", "MYDEEPAGENT_BUDGET_RUN_USD", "MYDEEPAGENT_BUDGET_RUN_WARN_USD", "MYDEEPAGENT_BUDGET_ON_HIT", "MYDEEPAGENT_LANG", "MYDEEPAGENT_LOG_LEVEL", "MYDEEPAGENT_OPENROUTER_API_KEY", "MYDEEPAGENT_OPENROUTER_BASE_URL", "MYDEEPAGENT_LANGSMITH_TRACING", "MYDEEPAGENT_LANGSMITH_API_KEY", "MYDEEPAGENT_LANGSMITH_PROJECT", "MYDEEPAGENT_DATABASE_URL", "MYDEEPAGENT_WORKSPACE_ROOT", "MYDEEPAGENT_DATA_DIR", "MYDEEPAGENT_CONFIG_DIR", "MYDEEPAGENT_STATE_DIR", "MYDEEPAGENT_DEFAULT_PERSONA", ] def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None: """Remove all MYDEEPAGENT_ env vars to isolate tests from the real environment.""" for key in _ENV_KEYS: monkeypatch.delenv(key, raising=False) # Also prevent dotenv file from being loaded monkeypatch.setenv("MYDEEPAGENT_ENV_FILE", "")