"""End-to-end argparse / dispatch tests via ``main()``. These exercise the full CLI surface from "argv in" → "exit code + captured stdout/stderr." Network I/O is mocked at the httpx layer with respx so no live server is needed. """ from __future__ import annotations import io import json import sys import httpx import pytest import respx from helix_cli.config import PAT_PREFIX from helix_cli.main import main FAKE_PAT = PAT_PREFIX + "abcdefghijklmnopqrstuvwxyz012345" BASE_URL = "https://helix.test.example" def _sse(payload: dict) -> tuple[bytes, dict]: body = f"event: message\ndata: {json.dumps(payload)}\n\n" return body.encode("utf-8"), {"content-type": "text/event-stream"} # ── basic dispatch ────────────────────────────────────────────────── def test_help_runs_without_error(cli_env, capsys: pytest.CaptureFixture[str]) -> None: """``helix --help`` exits zero and writes to stdout (argparse default).""" with pytest.raises(SystemExit) as exc: main(["--help"]) assert exc.value.code == 0 def test_no_command_prints_help_and_exits_zero( cli_env, capsys: pytest.CaptureFixture[str] ) -> None: rc = main([]) out = capsys.readouterr() assert rc == 0 assert "usage:" in out.out def test_unknown_subcommand_fails(cli_env) -> None: with pytest.raises(SystemExit): main(["this-is-not-a-command"]) def test_config_show_default_values( cli_env, capsys: pytest.CaptureFixture[str] ) -> None: rc = main(["--json", "config", "show"]) out = capsys.readouterr() assert rc == 0 parsed = json.loads(out.out) assert parsed["base_url"] == "http://localhost:8000" assert parsed["token_present"] is False # ── auth token set/clear ──────────────────────────────────────────── def test_auth_token_set_reads_stdin( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "stdin", io.StringIO(FAKE_PAT + "\n")) rc = main(["auth", "token", "set", "--token-stdin"]) out = capsys.readouterr() assert rc == 0 # Persisted token exists on disk assert json.loads(cli_env.read_text())["token"] == FAKE_PAT # And the success line redacts assert FAKE_PAT not in out.out assert "ab****" in out.out def test_auth_token_set_rejects_non_pat( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "stdin", io.StringIO("not-a-pat\n")) rc = main(["auth", "token", "set", "--token-stdin"]) out = capsys.readouterr() assert rc == 1 # USAGE assert "must start with" in out.err.lower() def test_auth_token_clear( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "stdin", io.StringIO(FAKE_PAT)) main(["auth", "token", "set", "--token-stdin"]) capsys.readouterr() rc = main(["auth", "token", "clear"]) capsys.readouterr() assert rc == 0 data = json.loads(cli_env.read_text()) assert "token" not in data # ── mcp commands ──────────────────────────────────────────────────── @respx.mock def test_mcp_tools_json_output( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) body, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "result": {"tools": [{"name": "whoami"}, {"name": "list_my_assets"}]}, }) respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) rc = main(["--json", "mcp", "tools"]) out = capsys.readouterr() assert rc == 0 parsed = json.loads(out.out) assert parsed["count"] == 2 names = {t["name"] for t in parsed["items"]} assert names == {"whoami", "list_my_assets"} @respx.mock def test_mcp_call_with_arg_pairs( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) body, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "result": { "isError": False, "content": [{"type": "text", "text": json.dumps({"items": []})}], }, }) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) rc = main([ "--json", "mcp", "call", "list_my_assets", "--arg", "page=2", "--arg", "page_size=50", "--arg", "include_disposed=false", ]) out = capsys.readouterr() assert rc == 0 sent = json.loads(route.calls[0].request.content.decode("utf-8")) # int and bool coercion should have happened assert sent["params"]["arguments"] == { "page": 2, "page_size": 50, "include_disposed": False, } parsed = json.loads(out.out) assert parsed == {"items": []} @respx.mock def test_mcp_call_with_json_arg( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) body, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "result": { "isError": False, "content": [{"type": "text", "text": "null"}], }, }) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) rc = main([ "mcp", "call", "submit_approval_request", "--json-arg", '{"template_id": "abc-123", "form": {"k":"v"}}', ]) assert rc == 0 sent = json.loads(route.calls[0].request.content.decode("utf-8")) assert sent["params"]["arguments"]["template_id"] == "abc-123" assert sent["params"]["arguments"]["form"] == {"k": "v"} def test_mcp_call_invalid_arg_pair( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) rc = main(["mcp", "call", "whoami", "--arg", "no-equals-sign"]) out = capsys.readouterr() assert rc == 1 assert "key=value" in out.err def test_mcp_call_invalid_json_arg( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) rc = main(["mcp", "call", "whoami", "--json-arg", "{not json"]) out = capsys.readouterr() assert rc == 1 assert "valid JSON" in out.err # ── convenience commands ──────────────────────────────────────────── @respx.mock def test_whoami_unwraps_text_block( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) inner = {"id": "u-1", "email": "user@example.com", "display_name": "U"} body, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "result": { "isError": False, "content": [{"type": "text", "text": json.dumps(inner)}], }, }) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) rc = main(["--json", "whoami"]) out = capsys.readouterr() assert rc == 0 sent = json.loads(route.calls[0].request.content.decode("utf-8")) assert sent["params"]["name"] == "whoami" assert json.loads(out.out) == inner @respx.mock def test_health_via_rest( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) respx.get(f"{BASE_URL}/api/v1/health").mock( return_value=httpx.Response( 200, json={"success": True, "data": {"status": "healthy"}, "meta": {}, "errors": []}, ) ) rc = main(["--json", "health"]) out = capsys.readouterr() assert rc == 0 assert json.loads(out.out) == {"status": "healthy"} @respx.mock def test_health_does_not_send_authorization( cli_env, monkeypatch: pytest.MonkeyPatch ) -> None: """``/health`` is a public probe — sending the PAT here would just leak it into proxy logs without buying anything. """ monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) route = respx.get(f"{BASE_URL}/api/v1/health").mock( return_value=httpx.Response( 200, json={"success": True, "data": {"status": "healthy"}, "meta": {}, "errors": []}, ) ) main(["health"]) sent = route.calls[0].request assert "authorization" not in {k.lower() for k in sent.headers} @respx.mock def test_ready_failure_maps_to_exit_4( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: """Defense in depth: a 503 + envelope error (LB / proxy / older server) still maps to exit 4, even though the real Helix /ready never returns it (see the degraded-payload test below). """ monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) respx.get(f"{BASE_URL}/api/v1/ready").mock( return_value=httpx.Response( 503, json={"success": False, "data": {"database": "error"}, "meta": {}, "errors": [{"code": "NOT_READY", "message": "db down"}]}, ) ) rc = main(["--json", "ready"]) assert rc == 4 @respx.mock def test_ready_degraded_payload_maps_to_exit_4( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: """The REAL /ready shape: the server ALWAYS replies 200 + success:true and carries degradation inside ``data`` ("status": "degraded", per-check "error"). A status-code-only check exits 0 while the DB is down — the CLI must inspect the payload and exit 4, naming the failing checks on stderr. """ monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) respx.get(f"{BASE_URL}/api/v1/ready").mock( return_value=httpx.Response( 200, json={"success": True, "data": {"status": "degraded", "checks": {"database": "ok", "redis": "error"}}, "meta": {}, "errors": []}, ) ) rc = main(["--json", "ready"]) out = capsys.readouterr() assert rc == 4 # stderr names the failing check; stdout carries no second document. assert "redis" in out.err assert "NOT_READY" in out.err assert out.out.strip() == "" @respx.mock def test_ready_ok_payload_exits_0( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) respx.get(f"{BASE_URL}/api/v1/ready").mock( return_value=httpx.Response( 200, json={"success": True, "data": {"status": "ready", "checks": {"database": "ok", "redis": "ok"}}, "meta": {}, "errors": []}, ) ) rc = main(["--json", "ready"]) out = capsys.readouterr() assert rc == 0 assert json.loads(out.out)["status"] == "ready" # ── argparse usage errors: exit 1 + redacted stderr ───────────────── def test_unknown_flag_exits_1_not_2(cli_env, capsys) -> None: """argparse's stock ``error()`` exits 2 — colliding with the documented exit-code contract (2 = UNAUTHORIZED). Usage errors are EXIT_USAGE (1). """ with pytest.raises(SystemExit) as exc: main(["--definitely-not-a-flag"]) assert exc.value.code == 1 def test_subparser_usage_error_exits_1(cli_env) -> None: """Subparsers must inherit the redacting parser class (argparse ``add_subparsers`` defaults ``parser_class`` to ``type(self)``) — a missing required sub-action errors inside the SUBparser, not the root parser. """ with pytest.raises(SystemExit) as exc: main(["config"]) assert exc.value.code == 1 def test_argparse_error_redacts_pat_in_stderr(cli_env, capsys) -> None: """A guessed ``--token helix_pat_…`` flag echoes the offending argv in argparse's error message ("unrecognized arguments: …") — that message must pass through the redacting stderr chokepoint, never landing the PAT in scrollback / CI logs. """ with pytest.raises(SystemExit) as exc: main(["--token", FAKE_PAT, "whoami"]) assert exc.value.code == 1 err = capsys.readouterr().err assert FAKE_PAT not in err assert f"{PAT_PREFIX}" in err # ── BrokenPipeError: downstream-close is success ───────────────────── def test_broken_pipe_exits_0_and_prints_nothing( cli_env, capsys, monkeypatch: pytest.MonkeyPatch ) -> None: """``helix … | head -1`` raises BrokenPipeError when head exits. Standard Unix behaviour: downstream-close is success (exit 0), no noisy "[LOCAL_IO] local error" on stderr. """ def _boom(*_a, **_k): raise BrokenPipeError(32, "Broken pipe") monkeypatch.setattr("helix_cli.output.write_out", _boom) rc = main(["--json", "config", "show"]) out = capsys.readouterr() assert rc == 0 assert out.err == "" # ── config set-url: ValueError is a usage error, not LOCAL_IO ─────── def test_config_set_url_invalid_scheme_is_usage_error( cli_env, capsys ) -> None: """``set_base_url``'s ValueError ("must start with http://…") used to escape into the generic handler → "[BAD_RESPONSE] local error" exit 5. It is the user's input being wrong: clean message, exit 1. """ rc = main(["config", "set-url", "ftp://nope.example.com"]) out = capsys.readouterr() assert rc == 1 assert "http://" in out.err assert "local error" not in out.err # ── safety: no token, no auto-retry ──────────────────────────────── def test_whoami_without_token_fails_with_helpful_message( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.delenv("HELIX_PAT", raising=False) monkeypatch.delenv("HELIX_TOKEN", raising=False) rc = main(["whoami"]) out = capsys.readouterr() assert rc == 1 # local usage error # Suggests the canonical fix assert "auth token set" in out.err or "HELIX_PAT" in out.err @respx.mock def test_no_auto_retry_on_failure( cli_env, monkeypatch: pytest.MonkeyPatch ) -> None: """A 500 must surface as one HTTP attempt, not retried. Auto-retry on a mutating tool would risk double-submitting an approval / ticket; the brief explicitly forbids it. We test the invariant on a read tool too, because the policy is "never retry, full stop, including reads." """ monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(500) ) rc = main(["whoami"]) assert rc == 5 assert route.call_count == 1 @respx.mock def test_mcp_tools_on_unauth_returns_exit_2( cli_env, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HELIX_BASE_URL", BASE_URL) monkeypatch.setenv("HELIX_PAT", FAKE_PAT) respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response( 401, json={"success": False, "errors": [ {"code": "UNAUTHORIZED", "message": "Invalid or expired token."} ]}, ) ) rc = main(["mcp", "tools"]) out = capsys.readouterr() assert rc == 2 assert "UNAUTHORIZED" in out.err