* 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>
This commit is contained in:
143
tests/test_sprint31.py
Normal file
143
tests/test_sprint31.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
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}"
|
||||
Reference in New Issue
Block a user