* feat: add custom endpoint fields to new profile form
* fix: skip config write tests when PyYAML not installed
The 4 unit tests for _write_endpoint_to_config imported yaml directly
without handling ImportError. Added pytest.importorskip('yaml') at
module level so the entire test class skips cleanly in environments
without PyYAML. Removed redundant per-method yaml imports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: wire frontend for custom endpoint fields in new profile form
- Add Base URL and API key inputs to the profile create form (index.html)
- Wire panels.js submitProfileCreate() to send base_url and api_key
- Clear new fields on form toggle/cancel
- Add client-side URL format validation (must start with http:// or https://)
- Add server-side URL format validation in routes.py (400 for invalid scheme)
- Add test_api_route_rejects_invalid_base_url() covering the new validation
- Base URL input has placeholder 'http://localhost:11434' per review suggestion
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
144 lines
6.0 KiB
Python
144 lines
6.0 KiB
Python
"""
|
|
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 ────────────────────────────────────────────────
|
|
|
|
_TEST_BASE = "http://127.0.0.1:8788"
|
|
|
|
|
|
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}"
|