/ tests / cli / test_cli_save_config_value.py
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