From 173b7e200972da1eb4f84588e507fe9008642bdc Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 13 Apr 2026 18:12:11 -0400 Subject: [PATCH] chore: updating tests --- honcho-cli/tests/test_commands.py | 141 ++++++++++++++++++++++++++++ honcho-cli/tests/test_config.py | 9 -- honcho-cli/tests/test_output.py | 55 ----------- honcho-cli/tests/test_validation.py | 77 --------------- 4 files changed, 141 insertions(+), 141 deletions(-) create mode 100644 honcho-cli/tests/test_commands.py delete mode 100644 honcho-cli/tests/test_output.py delete mode 100644 honcho-cli/tests/test_validation.py diff --git a/honcho-cli/tests/test_commands.py b/honcho-cli/tests/test_commands.py new file mode 100644 index 00000000..f2ae31ee --- /dev/null +++ b/honcho-cli/tests/test_commands.py @@ -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" diff --git a/honcho-cli/tests/test_config.py b/honcho-cli/tests/test_config.py index fb8178e0..67862004 100644 --- a/honcho-cli/tests/test_config.py +++ b/honcho-cli/tests/test_config.py @@ -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"})) diff --git a/honcho-cli/tests/test_output.py b/honcho-cli/tests/test_output.py deleted file mode 100644 index 81b4b93a..00000000 --- a/honcho-cli/tests/test_output.py +++ /dev/null @@ -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) diff --git a/honcho-cli/tests/test_validation.py b/honcho-cli/tests/test_validation.py deleted file mode 100644 index 31b070c8..00000000 --- a/honcho-cli/tests/test_validation.py +++ /dev/null @@ -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")