""" Tests for issue #170: new profile form with optional custom endpoint fields. Tests cover: 1. _write_endpoint_to_config writes base_url into config.yaml 2. _write_endpoint_to_config writes api_key into config.yaml 3. _write_endpoint_to_config writes both together 4. _write_endpoint_to_config merges with existing config (does not clobber) 5. _write_endpoint_to_config is a no-op when both args are None/empty 6. API route accepts base_url and api_key in POST body 7. Profile created via API has base_url in config.yaml """ import json import pathlib import shutil import os import pytest yaml = pytest.importorskip("yaml", reason="PyYAML required for config write tests") # ── 1-5: _write_endpoint_to_config unit tests ───────────────────────────────── class TestWriteEndpointToConfig: def test_writes_base_url(self, tmp_path): from api.profiles import _write_endpoint_to_config _write_endpoint_to_config(tmp_path, base_url="http://localhost:11434") cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert cfg["model"]["base_url"] == "http://localhost:11434" def test_writes_api_key(self, tmp_path): from api.profiles import _write_endpoint_to_config _write_endpoint_to_config(tmp_path, api_key="sk-local-test") cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert cfg["model"]["api_key"] == "sk-local-test" def test_writes_both(self, tmp_path): from api.profiles import _write_endpoint_to_config _write_endpoint_to_config(tmp_path, base_url="http://localhost:8080", api_key="mykey") cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert cfg["model"]["base_url"] == "http://localhost:8080" assert cfg["model"]["api_key"] == "mykey" def test_merges_with_existing_config(self, tmp_path): """Does not clobber other top-level config keys.""" existing = {"model": {"default": "gpt-4o", "provider": "openai"}, "agent": {"max_turns": 90}} (tmp_path / "config.yaml").write_text(yaml.dump(existing)) from api.profiles import _write_endpoint_to_config _write_endpoint_to_config(tmp_path, base_url="http://localhost:1234") cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) # Existing keys preserved assert cfg["model"]["default"] == "gpt-4o" assert cfg["model"]["provider"] == "openai" assert cfg["agent"]["max_turns"] == 90 # New key added assert cfg["model"]["base_url"] == "http://localhost:1234" def test_noop_when_both_none(self, tmp_path): from api.profiles import _write_endpoint_to_config _write_endpoint_to_config(tmp_path, base_url=None, api_key=None) assert not (tmp_path / "config.yaml").exists() def test_noop_when_both_empty_strings(self, tmp_path): from api.profiles import _write_endpoint_to_config _write_endpoint_to_config(tmp_path, base_url="", api_key="") assert not (tmp_path / "config.yaml").exists() # ── 6-7: API integration tests ──────────────────────────────────────────────── from tests._pytest_port import BASE as _TEST_BASE def _post(path, body=None): import urllib.request data = json.dumps(body or {}).encode() req = urllib.request.Request( _TEST_BASE + path, data=data, headers={"Content-Type": "application/json"} ) try: with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()), None except urllib.error.HTTPError as e: try: return json.loads(e.read()), e.code except Exception: return {}, e.code class TestProfileCreateAPIWithEndpoint: _PROFILE_NAME = "test-ep-sprint31" def _cleanup(self): """Remove the test profile from wherever hermes_cli placed it.""" home_hermes = pathlib.Path.home() / ".hermes" # Walk all profile roots: real ~/.hermes, and any subdirs that might be HERMES_HOME roots_to_check = set() roots_to_check.add(home_hermes) for root, dirs, _ in os.walk(str(home_hermes)): if "profiles" in dirs: roots_to_check.add(pathlib.Path(root)) if root.count(os.sep) - str(home_hermes).count(os.sep) > 4: break # don't recurse too deep for search_root in roots_to_check: candidate = search_root / "profiles" / self._PROFILE_NAME if candidate.exists(): shutil.rmtree(candidate) def setup_method(self, _): self._cleanup() def teardown_method(self, _): self._cleanup() def test_api_route_accepts_base_url(self, test_server): """POST /api/profile/create with base_url returns ok:True.""" data, err = _post("/api/profile/create", { "name": self._PROFILE_NAME, "base_url": "http://localhost:11434", }) assert err is None, f"Expected 200, got {err}: {data}" assert data.get("ok") is True def test_api_route_writes_base_url_to_config(self, test_server): """Route accepts base_url and returns profile metadata. The actual config.yaml write is covered by the unit tests above. """ data, err = _post("/api/profile/create", { "name": self._PROFILE_NAME, "base_url": "http://localhost:9999", }) assert err is None, f"Expected 200, got {err}: {data}" assert data.get("ok") is True assert data.get("profile", {}).get("path"), f"API response missing profile.path: {data}" def test_api_route_rejects_invalid_base_url(self, test_server): """POST /api/profile/create with a non-http base_url returns 400.""" data, err = _post("/api/profile/create", { "name": self._PROFILE_NAME, "base_url": "ftp://localhost:11434", }) assert err == 400, f"Expected 400, got {err}: {data}"