chore: updating tests
This commit is contained in:
parent
25429027ca
commit
173b7e2009
|
|
@ -0,0 +1,141 @@
|
|||
"""Command-level tests: init flow, destructive confirms, JSON output contract, exit codes.
|
||||
|
||||
Uses Typer's CliRunner against the real `app`. stdout is not a TTY under
|
||||
CliRunner, so `use_json()` returns True and the CLI emits JSON/ndjson —
|
||||
which is exactly what scripts and agents consume.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from honcho_cli.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(tmp_path, monkeypatch):
|
||||
"""Isolated config file + clean HONCHO_* env."""
|
||||
f = tmp_path / "config.json"
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
|
||||
monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f)
|
||||
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
|
||||
monkeypatch.delenv(k)
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. `honcho init` end-to-end
|
||||
|
||||
class TestInit:
|
||||
def test_first_run_writes_exact_shape(self, cfg, runner):
|
||||
"""First run with --api-key + --base-url writes apiKey + environmentUrl only."""
|
||||
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "--api-key", "test-key-123", "--base-url", "http://localhost:8000"],
|
||||
)
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert json.loads(cfg.read_text()) == {
|
||||
"environmentUrl": "http://localhost:8000",
|
||||
"apiKey": "test-key-123",
|
||||
}
|
||||
|
||||
def test_preserves_foreign_keys(self, cfg, runner):
|
||||
"""Second run must not clobber sibling-tool keys (`hosts`, `sessions`, ...)."""
|
||||
cfg.write_text(json.dumps({
|
||||
"apiKey": "old",
|
||||
"environmentUrl": "http://old.example",
|
||||
"hosts": {"claude_code": {"peerName": "ajspig"}},
|
||||
"sessions": {"/Users/ajspig": "home-chat"},
|
||||
"sessionStrategy": "chat-instance",
|
||||
}))
|
||||
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "--api-key", "new-key", "--base-url", "https://api.honcho.dev"],
|
||||
)
|
||||
assert result.exit_code == 0, result.stderr
|
||||
on_disk = json.loads(cfg.read_text())
|
||||
assert on_disk["apiKey"] == "new-key"
|
||||
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
|
||||
assert on_disk["hosts"] == {"claude_code": {"peerName": "ajspig"}}
|
||||
assert on_disk["sessions"] == {"/Users/ajspig": "home-chat"}
|
||||
assert on_disk["sessionStrategy"] == "chat-instance"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Destructive-confirm guards
|
||||
|
||||
class TestDestructiveConfirm:
|
||||
def test_workspace_delete_aborts_on_no(self, cfg, runner):
|
||||
"""`workspace delete` without --yes: 'n' at prompt → no API call, non-zero exit."""
|
||||
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
|
||||
fake = MagicMock()
|
||||
fake.sessions.return_value = MagicMock(has_next_page=lambda: False, _raw_items=[])
|
||||
with patch("honcho_cli.main.get_client", return_value=(fake, MagicMock())), \
|
||||
patch("honcho_cli.commands.workspace._with_workspace", return_value=fake):
|
||||
result = runner.invoke(app, ["workspace", "delete", "ws1"], input="n\n")
|
||||
assert result.exit_code != 0
|
||||
fake.delete_workspace.assert_not_called()
|
||||
|
||||
def test_session_delete_aborts_on_no(self, cfg, runner):
|
||||
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
|
||||
session = MagicMock()
|
||||
client = MagicMock()
|
||||
client.session.return_value = session
|
||||
config = MagicMock(session_id="s1", workspace_id="ws1")
|
||||
with patch("honcho_cli.main.get_client", return_value=(client, config)):
|
||||
result = runner.invoke(app, ["session", "delete", "s1"], input="n\n")
|
||||
assert result.exit_code != 0
|
||||
session.delete.assert_not_called()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. JSON output contract — scripts pipe these
|
||||
|
||||
class TestJsonContract:
|
||||
def test_workspace_list_ndjson_shape(self, cfg, runner):
|
||||
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
|
||||
client = MagicMock()
|
||||
client.workspaces.return_value = ["ws-a", "ws-b"]
|
||||
with patch("honcho_cli.main.get_client", return_value=(client, MagicMock())):
|
||||
result = runner.invoke(app, ["workspace", "list"])
|
||||
assert result.exit_code == 0, result.stderr
|
||||
lines = [json.loads(line) for line in result.stdout.strip().splitlines() if line.strip()]
|
||||
assert lines == [{"id": "ws-a"}, {"id": "ws-b"}]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Exit codes on error
|
||||
|
||||
class TestExitCodes:
|
||||
def test_no_workspace_scoped_exits_nonzero_with_code(self, cfg, runner):
|
||||
"""Running a workspace-scoped command with no workspace → NO_WORKSPACE on stderr, exit 1."""
|
||||
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
|
||||
result = runner.invoke(app, ["peer", "list"])
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stderr)["error"]["code"] == "NO_WORKSPACE"
|
||||
|
||||
def test_not_found_exits_nonzero_with_code(self, cfg, runner):
|
||||
"""SDK NotFoundError → structured error, exit 1."""
|
||||
from honcho import NotFoundError
|
||||
|
||||
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
|
||||
client = MagicMock()
|
||||
client.peer.return_value.get_card.side_effect = NotFoundError("not found")
|
||||
config = MagicMock(peer_id="missing", session_id="", workspace_id="ws1")
|
||||
with patch("honcho_cli.main.get_client", return_value=(client, config)):
|
||||
result = runner.invoke(app, ["peer", "inspect", "missing", "-w", "ws1"])
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stderr)["error"]["code"] == "PEER_NOT_FOUND"
|
||||
|
|
@ -35,15 +35,6 @@ class TestLoad:
|
|||
assert loaded.base_url == "http://localhost:8000"
|
||||
assert loaded.api_key == "k"
|
||||
|
||||
def test_ignores_unknown_url_fields(self, cfg_path):
|
||||
"""Legacy `environment` / `baseUrl` keys are NOT consulted — only environmentUrl."""
|
||||
cfg_path.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"environment": "production",
|
||||
"baseUrl": "https://stale.example.com",
|
||||
}))
|
||||
assert CLIConfig.load().base_url == "https://api.honcho.dev" # default
|
||||
|
||||
def test_api_key_and_base_url_ignore_env(self, cfg_path, monkeypatch):
|
||||
"""Both apiKey and base_url must come from config.json — env vars are ignored at runtime."""
|
||||
cfg_path.write_text(json.dumps({"environmentUrl": "https://api.honcho.dev"}))
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
"""Tests for output formatting."""
|
||||
|
||||
import json
|
||||
|
||||
from honcho_cli.output import is_tty, set_json_mode, set_quiet_mode, use_json
|
||||
|
||||
|
||||
class TestOutputModes:
|
||||
def test_force_json(self):
|
||||
set_json_mode(True)
|
||||
assert use_json() is True
|
||||
set_json_mode(False)
|
||||
|
||||
def test_non_tty_defaults_to_json(self):
|
||||
# In test context, stdout is not a TTY
|
||||
set_json_mode(False)
|
||||
assert use_json() is True # pytest redirects stdout
|
||||
|
||||
|
||||
class TestPrintNdjson:
|
||||
def test_ndjson_format(self, capsys):
|
||||
from honcho_cli.output import print_ndjson
|
||||
|
||||
items = [{"id": "1", "name": "a"}, {"id": "2", "name": "b"}]
|
||||
print_ndjson(items)
|
||||
output = capsys.readouterr().out
|
||||
lines = output.strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert json.loads(lines[0]) == {"id": "1", "name": "a"}
|
||||
assert json.loads(lines[1]) == {"id": "2", "name": "b"}
|
||||
|
||||
|
||||
class TestPrintJson:
|
||||
def test_json_format(self, capsys):
|
||||
from honcho_cli.output import print_json
|
||||
|
||||
data = {"workspace_id": "test", "peer_count": 5}
|
||||
print_json(data)
|
||||
output = capsys.readouterr().out
|
||||
parsed = json.loads(output)
|
||||
assert parsed["workspace_id"] == "test"
|
||||
assert parsed["peer_count"] == 5
|
||||
|
||||
|
||||
class TestPrintError:
|
||||
def test_error_json_format(self, capsys):
|
||||
set_json_mode(True)
|
||||
from honcho_cli.output import print_error
|
||||
|
||||
print_error("PEER_NOT_FOUND", "Peer 'abc' not found", {"peer_id": "abc"})
|
||||
output = capsys.readouterr().err
|
||||
parsed = json.loads(output)
|
||||
assert parsed["error"]["code"] == "PEER_NOT_FOUND"
|
||||
assert parsed["error"]["details"]["peer_id"] == "abc"
|
||||
set_json_mode(False)
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
"""Tests for input hardening / validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_cli.validation import validate_resource_id, validate_workspace_name
|
||||
|
||||
|
||||
class TestValidateResourceId:
|
||||
def test_valid_id(self):
|
||||
assert validate_resource_id("abc123") == "abc123"
|
||||
|
||||
def test_valid_nanoid(self):
|
||||
assert validate_resource_id("V1StGXR8_Z5jdHi6B-myT") == "V1StGXR8_Z5jdHi6B-myT"
|
||||
|
||||
def test_empty_id(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("")
|
||||
|
||||
def test_question_mark(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc?def")
|
||||
|
||||
def test_hash(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc#def")
|
||||
|
||||
def test_percent(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc%def")
|
||||
|
||||
def test_control_chars(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc\x00def")
|
||||
|
||||
def test_null_byte(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc\x01def")
|
||||
|
||||
def test_path_traversal(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("../etc/passwd")
|
||||
|
||||
def test_forward_slash(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc/def")
|
||||
|
||||
def test_backslash(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc\\def")
|
||||
|
||||
def test_tab(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_resource_id("abc\tdef")
|
||||
|
||||
|
||||
class TestValidateWorkspaceName:
|
||||
def test_valid_name(self):
|
||||
assert validate_workspace_name("my-workspace") == "my-workspace"
|
||||
|
||||
def test_valid_underscore(self):
|
||||
assert validate_workspace_name("my_workspace_123") == "my_workspace_123"
|
||||
|
||||
def test_empty_name(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_workspace_name("")
|
||||
|
||||
def test_spaces(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_workspace_name("my workspace")
|
||||
|
||||
def test_special_chars(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_workspace_name("my@workspace")
|
||||
|
||||
def test_dots(self):
|
||||
with pytest.raises(SystemExit):
|
||||
validate_workspace_name("my.workspace")
|
||||
Loading…
Reference in New Issue