test_cli_save_config_value.py
1 """Tests for save_config_value() in cli.py — atomic write behavior.""" 2 3 import os 4 import yaml 5 from pathlib import Path 6 from unittest.mock import patch, MagicMock 7 8 import pytest 9 10 11 class TestSaveConfigValueAtomic: 12 """save_config_value() must use atomic_yaml_write to avoid data loss.""" 13 14 @pytest.fixture 15 def config_env(self, tmp_path, monkeypatch): 16 """Isolated config environment with a writable config.yaml.""" 17 hermes_home = tmp_path / ".hermes" 18 hermes_home.mkdir() 19 config_path = hermes_home / "config.yaml" 20 config_path.write_text(yaml.dump({ 21 "model": {"default": "test-model", "provider": "openrouter"}, 22 "display": {"skin": "default"}, 23 })) 24 monkeypatch.setattr("cli._hermes_home", hermes_home) 25 return config_path 26 27 def test_calls_atomic_yaml_write(self, config_env, monkeypatch): 28 """save_config_value must route through atomic_yaml_write, not bare open().""" 29 mock_atomic = MagicMock() 30 monkeypatch.setattr("utils.atomic_yaml_write", mock_atomic) 31 32 from cli import save_config_value 33 save_config_value("display.skin", "mono") 34 35 mock_atomic.assert_called_once() 36 written_path, written_data = mock_atomic.call_args[0] 37 assert Path(written_path) == config_env 38 assert written_data["display"]["skin"] == "mono" 39 40 def test_preserves_existing_keys(self, config_env): 41 """Writing a new key must not clobber existing config entries.""" 42 from cli import save_config_value 43 save_config_value("agent.max_turns", 50) 44 45 result = yaml.safe_load(config_env.read_text()) 46 assert result["model"]["default"] == "test-model" 47 assert result["model"]["provider"] == "openrouter" 48 assert result["display"]["skin"] == "default" 49 assert result["agent"]["max_turns"] == 50 50 51 def test_creates_nested_keys(self, config_env): 52 """Dot-separated paths create intermediate dicts as needed.""" 53 from cli import save_config_value 54 save_config_value("auxiliary.compression.model", "google/gemini-3-flash-preview") 55 56 result = yaml.safe_load(config_env.read_text()) 57 assert result["auxiliary"]["compression"]["model"] == "google/gemini-3-flash-preview" 58 59 def test_overwrites_existing_value(self, config_env): 60 """Updating an existing key replaces the value.""" 61 from cli import save_config_value 62 save_config_value("display.skin", "ares") 63 64 result = yaml.safe_load(config_env.read_text()) 65 assert result["display"]["skin"] == "ares" 66 67 def test_preserves_env_ref_templates_in_unrelated_fields(self, config_env): 68 """The /model --global persistence path must not inline env-backed secrets.""" 69 config_env.write_text(yaml.dump({ 70 "custom_providers": [{ 71 "name": "tuzi", 72 "api_key": "${TU_ZI_API_KEY}", 73 "model": "claude-opus-4-6", 74 }], 75 "model": {"default": "test-model", "provider": "openrouter"}, 76 })) 77 78 from cli import save_config_value 79 save_config_value("model.default", "doubao-pro") 80 81 result = yaml.safe_load(config_env.read_text()) 82 assert result["model"]["default"] == "doubao-pro" 83 assert result["custom_providers"][0]["api_key"] == "${TU_ZI_API_KEY}" 84 85 def test_file_not_truncated_on_error(self, config_env, monkeypatch): 86 """If atomic_yaml_write raises, the original file is untouched.""" 87 original_content = config_env.read_text() 88 89 def exploding_write(*args, **kwargs): 90 raise OSError("disk full") 91 92 monkeypatch.setattr("utils.atomic_yaml_write", exploding_write) 93 94 from cli import save_config_value 95 result = save_config_value("display.skin", "broken") 96 97 assert result is False 98 assert config_env.read_text() == original_content