09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
633 lines
20 KiB
Python
633 lines
20 KiB
Python
"""``with_client`` routes errors through ``handle_errors``.
|
|
|
|
Before the fix, ``@with_client`` ran its own ad-hoc ``try/except FileNotFoundError``
|
|
+ broad ``except Exception``, so typed library exceptions got squashed into a
|
|
generic ``"ERROR"`` payload (or a plain "Error: ..." stderr line) with no
|
|
actionable hint.
|
|
|
|
After the with_client refactor:
|
|
|
|
* ``AuthError`` → "Run 'notebooklm login' to re-authenticate." hint, exit 1
|
|
* ``RateLimitError`` → "Retry after Ns" hint, exit 1
|
|
* ``FileNotFoundError`` (missing storage file) → same AUTH_REQUIRED UX, exit 1
|
|
* JSON variants emit parseable JSON with the appropriate ``code`` + nonzero exit
|
|
* Happy path is unchanged (exit 0, normal output)
|
|
|
|
The tests use a throwaway Click command registered onto a fresh ``click.Group``
|
|
so they exercise the decorator in isolation from the production CLI surface.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Generator
|
|
from contextlib import contextmanager
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import click
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
import notebooklm.cli.auth_runtime as auth_runtime
|
|
import notebooklm.cli.error_handler as error_handler_module
|
|
import notebooklm.cli.helpers as helpers_module
|
|
from notebooklm.cli.helpers import with_auth_and_errors, with_client
|
|
from notebooklm.exceptions import AuthError, RateLimitError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def runner() -> CliRunner:
|
|
# Click 8.2+ separates stdout/stderr in ``CliRunner`` by default, which is
|
|
# what we need to assert hints went to stderr and JSON to stdout.
|
|
return CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def stubbed_auth(monkeypatch) -> Generator[None, None, None]:
|
|
"""Replace ``get_auth_tokens`` with a no-op so the body runs.
|
|
|
|
Tests that want to force a ``FileNotFoundError`` from the auth bootstrap
|
|
will override this fixture's behavior locally.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
fake_auth = MagicMock(name="AuthTokens-stub")
|
|
with patch.object(helpers_module, "get_auth_tokens", return_value=fake_auth):
|
|
yield
|
|
|
|
|
|
def _build_cli(body):
|
|
"""Wrap ``body`` (a coroutine factory) in a minimal ``with_client`` command.
|
|
|
|
The resulting ``Group`` exposes a single ``run`` subcommand with optional
|
|
``--json`` flag, matching the calling convention every production CLI
|
|
command uses.
|
|
"""
|
|
|
|
@click.group()
|
|
@click.option("-v", "--verbose", count=True)
|
|
@click.pass_context
|
|
def cli(ctx, verbose):
|
|
ctx.ensure_object(dict)
|
|
|
|
@cli.command("run")
|
|
@click.option("--json", "json_output", is_flag=True)
|
|
@with_client
|
|
def run(ctx, json_output, client_auth):
|
|
return body(client_auth)
|
|
|
|
return cli
|
|
|
|
|
|
def _make_click_context(verbose: int = 0) -> click.Context:
|
|
@click.group()
|
|
def cli():
|
|
pass
|
|
|
|
@click.command()
|
|
def run():
|
|
pass
|
|
|
|
root_ctx = click.Context(cli)
|
|
root_ctx.params["verbose"] = verbose
|
|
return click.Context(run, parent=root_ctx)
|
|
|
|
|
|
class _FakeClientManager:
|
|
def __init__(self, client):
|
|
self.client = client
|
|
self.entered = False
|
|
self.exited = False
|
|
|
|
async def __aenter__(self):
|
|
self.entered = True
|
|
return self.client
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
self.exited = True
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared primitive behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_with_auth_and_errors_uses_custom_auth_loader() -> None:
|
|
sentinel_auth = MagicMock(name="custom-auth")
|
|
seen: dict[str, object] = {}
|
|
ctx = _make_click_context()
|
|
|
|
def _loader(loader_ctx: click.Context):
|
|
seen["ctx"] = loader_ctx
|
|
return sentinel_auth
|
|
|
|
async def _body(auth):
|
|
seen["auth"] = auth
|
|
return "ok"
|
|
|
|
result = with_auth_and_errors(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=False,
|
|
body=_body,
|
|
auth_loader=_loader,
|
|
)
|
|
|
|
assert result == "ok"
|
|
assert seen == {"ctx": ctx, "auth": sentinel_auth}
|
|
|
|
|
|
def test_with_auth_and_errors_default_auth_loader_is_looked_up_at_call_time() -> None:
|
|
sentinel_auth = MagicMock(name="default-auth")
|
|
ctx = _make_click_context()
|
|
|
|
async def _body(auth):
|
|
return auth
|
|
|
|
with patch.object(helpers_module, "get_auth_tokens", return_value=sentinel_auth) as loader:
|
|
result = with_auth_and_errors(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=False,
|
|
body=_body,
|
|
)
|
|
|
|
loader.assert_called_once_with(ctx)
|
|
assert result is sentinel_auth
|
|
|
|
|
|
def test_auth_runtime_default_auth_loader_preserves_helpers_patch_seam() -> None:
|
|
sentinel_auth = MagicMock(name="default-auth")
|
|
ctx = _make_click_context()
|
|
|
|
async def _body(auth):
|
|
return auth
|
|
|
|
with patch.object(helpers_module, "get_auth_tokens", return_value=sentinel_auth) as loader:
|
|
result = auth_runtime.with_auth_and_errors(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=False,
|
|
body=_body,
|
|
)
|
|
|
|
loader.assert_called_once_with(ctx)
|
|
assert result is sentinel_auth
|
|
|
|
|
|
def test_run_client_workflow_opens_client_with_loaded_auth() -> None:
|
|
sentinel_auth = MagicMock(name="auth")
|
|
sentinel_client = MagicMock(name="client")
|
|
manager = _FakeClientManager(sentinel_client)
|
|
seen: dict[str, object] = {}
|
|
ctx = _make_click_context()
|
|
|
|
def _loader(loader_ctx: click.Context):
|
|
seen["ctx"] = loader_ctx
|
|
return sentinel_auth
|
|
|
|
def _client_factory(auth):
|
|
seen["factory_auth"] = auth
|
|
return manager
|
|
|
|
async def _body(client):
|
|
seen["client"] = client
|
|
return "ok"
|
|
|
|
result = auth_runtime.run_client_workflow(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=False,
|
|
body=_body,
|
|
auth_loader=_loader,
|
|
client_factory=_client_factory,
|
|
)
|
|
|
|
assert result == "ok"
|
|
assert seen == {
|
|
"ctx": ctx,
|
|
"factory_auth": sentinel_auth,
|
|
"client": sentinel_client,
|
|
}
|
|
assert manager.entered is True
|
|
assert manager.exited is True
|
|
|
|
|
|
def test_run_client_workflow_body_error_handler_can_handle_body_exception() -> None:
|
|
sentinel_auth = MagicMock(name="auth")
|
|
sentinel_client = MagicMock(name="client")
|
|
manager = _FakeClientManager(sentinel_client)
|
|
ctx = _make_click_context()
|
|
|
|
async def _body(_client):
|
|
raise RuntimeError("body failed")
|
|
|
|
def _handle(exc: Exception) -> str:
|
|
return f"handled: {exc}"
|
|
|
|
result = auth_runtime.run_client_workflow(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=False,
|
|
body=_body,
|
|
auth_loader=lambda _ctx: sentinel_auth,
|
|
client_factory=lambda _auth: manager,
|
|
body_error_handler=_handle,
|
|
)
|
|
|
|
assert result == "handled: body failed"
|
|
assert manager.entered is True
|
|
assert manager.exited is True
|
|
|
|
|
|
def test_with_auth_and_errors_passes_verbose_json_to_current_handle_errors() -> None:
|
|
calls: list[dict[str, bool]] = []
|
|
ctx = _make_click_context(verbose=1)
|
|
|
|
@contextmanager
|
|
def fake_handle_errors(*, verbose: bool, json_output: bool):
|
|
calls.append({"verbose": verbose, "json_output": json_output})
|
|
yield
|
|
|
|
async def _body(_auth):
|
|
return "real body result"
|
|
|
|
def fake_run_async(coro):
|
|
coro.close()
|
|
return "patched run result"
|
|
|
|
# ``with_auth_and_errors`` imports this at call time, so patch the source module.
|
|
with (
|
|
patch.object(error_handler_module, "handle_errors", fake_handle_errors),
|
|
patch.object(helpers_module, "run_async", side_effect=fake_run_async) as run_async,
|
|
):
|
|
result = with_auth_and_errors(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=True,
|
|
body=_body,
|
|
auth_loader=lambda _ctx: MagicMock(name="auth"),
|
|
)
|
|
|
|
assert result == "patched run result"
|
|
assert calls == [{"verbose": True, "json_output": True}]
|
|
assert run_async.call_count == 1
|
|
|
|
|
|
def test_with_auth_and_errors_auth_file_not_found_uses_auth_handler() -> None:
|
|
ctx = _make_click_context()
|
|
|
|
async def _never_called(_auth):
|
|
raise AssertionError("body should not run when auth bootstrap fails")
|
|
|
|
def _loader(_ctx):
|
|
raise FileNotFoundError("missing storage")
|
|
|
|
with (
|
|
patch.object(helpers_module, "handle_auth_error", side_effect=SystemExit(1)) as auth_error,
|
|
pytest.raises(SystemExit) as exc_info,
|
|
):
|
|
with_auth_and_errors(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=True,
|
|
body=_never_called,
|
|
auth_loader=_loader,
|
|
)
|
|
|
|
assert exc_info.value.code == 1
|
|
auth_error.assert_called_once_with(True)
|
|
|
|
|
|
def test_with_auth_and_errors_body_file_not_found_reaches_handle_errors(capsys) -> None:
|
|
ctx = _make_click_context()
|
|
|
|
async def _body(_auth):
|
|
raise FileNotFoundError("missing command input")
|
|
|
|
with (
|
|
patch.object(helpers_module, "handle_auth_error") as auth_error,
|
|
pytest.raises(SystemExit) as exc_info,
|
|
):
|
|
with_auth_and_errors(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=True,
|
|
body=_body,
|
|
auth_loader=lambda _ctx: MagicMock(name="auth"),
|
|
)
|
|
|
|
assert exc_info.value.code == 2
|
|
auth_error.assert_not_called()
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "UNEXPECTED_ERROR"
|
|
assert "missing command input" in payload["message"]
|
|
|
|
|
|
def test_with_auth_and_errors_non_file_auth_failure_reaches_handle_errors(capsys) -> None:
|
|
ctx = _make_click_context()
|
|
|
|
async def _never_called(_auth):
|
|
raise AssertionError("body should not run when auth bootstrap fails")
|
|
|
|
def _loader(_ctx):
|
|
raise ValueError("malformed storage JSON")
|
|
|
|
with (
|
|
patch.object(helpers_module, "handle_auth_error") as auth_error,
|
|
pytest.raises(SystemExit) as exc_info,
|
|
):
|
|
with_auth_and_errors(
|
|
ctx,
|
|
command_name="run",
|
|
json_output=True,
|
|
body=_never_called,
|
|
auth_loader=_loader,
|
|
)
|
|
|
|
assert exc_info.value.code == 2
|
|
auth_error.assert_not_called()
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "UNEXPECTED_ERROR"
|
|
assert "malformed storage JSON" in payload["message"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Text-mode failure paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_auth_error_surfaces_login_hint(runner: CliRunner, stubbed_auth) -> None:
|
|
"""``AuthError`` → "run notebooklm login" hint, exit code 1."""
|
|
|
|
async def _raise(_auth):
|
|
raise AuthError("Token expired")
|
|
|
|
cli = _build_cli(_raise)
|
|
result = runner.invoke(cli, ["run"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1, result.stderr
|
|
assert "Authentication error" in result.stderr
|
|
assert "notebooklm login" in result.stderr
|
|
|
|
|
|
def test_rate_limit_error_surfaces_retry_hint(runner: CliRunner, stubbed_auth) -> None:
|
|
"""``RateLimitError`` → "Retry after Ns" hint, exit code 1."""
|
|
|
|
async def _raise(_auth):
|
|
raise RateLimitError("Too many requests", retry_after=42)
|
|
|
|
cli = _build_cli(_raise)
|
|
result = runner.invoke(cli, ["run"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1, result.stderr
|
|
assert "Rate limited" in result.stderr
|
|
assert "42" in result.stderr
|
|
|
|
|
|
def test_file_not_found_routes_to_auth_hint(runner: CliRunner, monkeypatch) -> None:
|
|
"""Missing storage file → AUTH_REQUIRED UX (same as no-login path)."""
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
|
|
async def _never_called(_auth):
|
|
raise AssertionError("body should not run when auth bootstrap fails")
|
|
|
|
with patch.object(
|
|
helpers_module,
|
|
"get_auth_tokens",
|
|
side_effect=FileNotFoundError("missing storage"),
|
|
):
|
|
cli = _build_cli(_never_called)
|
|
result = runner.invoke(cli, ["run"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1, result.stderr
|
|
# ``handle_auth_error`` prints rich console output; we just need the
|
|
# actionable hint to be reachable somewhere across stdout/stderr/output.
|
|
combined = (result.stdout or "") + (result.stderr or "") + (result.output or "")
|
|
assert "notebooklm login" in combined.lower()
|
|
|
|
|
|
def test_auth_bootstrap_non_filenotfound_routes_through_handle_errors(
|
|
runner: CliRunner, monkeypatch
|
|
) -> None:
|
|
"""Non-FileNotFoundError exceptions during auth bootstrap reach ``handle_errors``.
|
|
|
|
Regression guard for Gemini feedback on PR #454: previously the auth
|
|
bootstrap lived OUTSIDE ``handle_errors``, so a ``ValueError`` from
|
|
malformed storage JSON or an ``AuthError`` during token extraction would
|
|
bubble unhandled instead of getting the centralized hint + typed code.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
|
|
async def _never_called(_auth):
|
|
raise AssertionError("body should not run when auth bootstrap fails")
|
|
|
|
# AuthError surfaces with the actionable "run notebooklm login" hint and exit 1.
|
|
with patch.object(
|
|
helpers_module,
|
|
"get_auth_tokens",
|
|
side_effect=AuthError("token refresh failed"),
|
|
):
|
|
cli = _build_cli(_never_called)
|
|
result = runner.invoke(cli, ["run"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1, result.stderr
|
|
assert "notebooklm login" in result.stderr
|
|
|
|
|
|
def test_auth_bootstrap_malformed_json_routes_through_handle_errors(
|
|
runner: CliRunner, monkeypatch
|
|
) -> None:
|
|
"""``ValueError`` (e.g., malformed storage JSON) during auth bootstrap exits 2.
|
|
|
|
Without ``handle_errors`` wrapping the bootstrap this would bubble as an
|
|
uncaught traceback.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
|
|
async def _never_called(_auth):
|
|
raise AssertionError("body should not run when auth bootstrap fails")
|
|
|
|
with patch.object(
|
|
helpers_module,
|
|
"get_auth_tokens",
|
|
side_effect=ValueError("malformed storage JSON"),
|
|
):
|
|
cli = _build_cli(_never_called)
|
|
result = runner.invoke(cli, ["run", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 2, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "UNEXPECTED_ERROR"
|
|
|
|
|
|
def test_auth_bootstrap_non_filenotfound_logs_failed_result(
|
|
runner: CliRunner, monkeypatch, caplog
|
|
) -> None:
|
|
"""Bootstrap exceptions other than FileNotFoundError emit ``log_result('failed', ...)``.
|
|
|
|
Regression guard for Gemini feedback on PR #455
|
|
(``cli.auth_runtime.with_auth_and_errors``): previously
|
|
only ``FileNotFoundError`` produced the structured debug log entry, so an
|
|
``AuthError`` during bootstrap would be handled by ``handle_errors`` but the
|
|
timing/cmd-name pair never reached the debug log — an observability gap when
|
|
triaging auth failures via ``NOTEBOOKLM_DEBUG=1``.
|
|
"""
|
|
import logging
|
|
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
|
|
async def _never_called(_auth):
|
|
raise AssertionError("body should not run when auth bootstrap fails")
|
|
|
|
with (
|
|
caplog.at_level(logging.DEBUG, logger="notebooklm.cli"),
|
|
patch.object(
|
|
helpers_module,
|
|
"get_auth_tokens",
|
|
side_effect=AuthError("token refresh failed"),
|
|
),
|
|
):
|
|
cli = _build_cli(_never_called)
|
|
result = runner.invoke(cli, ["run"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1, result.stderr
|
|
failed_records = [
|
|
r for r in caplog.records if "failed" in r.getMessage() and "run" in r.getMessage()
|
|
]
|
|
assert failed_records, (
|
|
f"Expected at least one log_result('failed', ...) record, got: "
|
|
f"{[r.getMessage() for r in caplog.records]}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON-mode failure paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_auth_error_json_payload(runner: CliRunner, stubbed_auth) -> None:
|
|
"""JSON mode: AuthError → parseable JSON with AUTH_ERROR code, nonzero exit."""
|
|
|
|
async def _raise(_auth):
|
|
raise AuthError("Token expired")
|
|
|
|
cli = _build_cli(_raise)
|
|
result = runner.invoke(cli, ["run", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code != 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "AUTH_ERROR"
|
|
assert "Token expired" in payload["message"]
|
|
|
|
|
|
def test_rate_limit_error_json_payload(runner: CliRunner, stubbed_auth) -> None:
|
|
"""JSON mode: RateLimitError → parseable JSON with RATE_LIMITED code, retry_after."""
|
|
|
|
async def _raise(_auth):
|
|
raise RateLimitError("Too many requests", retry_after=30)
|
|
|
|
cli = _build_cli(_raise)
|
|
result = runner.invoke(cli, ["run", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code != 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "RATE_LIMITED"
|
|
assert payload["retry_after"] == 30
|
|
|
|
|
|
def test_file_not_found_json_payload(runner: CliRunner, monkeypatch) -> None:
|
|
"""JSON mode: missing storage → AUTH_REQUIRED JSON, nonzero exit."""
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
|
|
async def _never_called(_auth):
|
|
raise AssertionError("body should not run when auth bootstrap fails")
|
|
|
|
with patch.object(
|
|
helpers_module,
|
|
"get_auth_tokens",
|
|
side_effect=FileNotFoundError("missing storage"),
|
|
):
|
|
cli = _build_cli(_never_called)
|
|
result = runner.invoke(cli, ["run", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code != 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "AUTH_REQUIRED"
|
|
|
|
|
|
def test_command_body_file_not_found_is_not_auth_required(runner: CliRunner, stubbed_auth) -> None:
|
|
"""Command-body ``FileNotFoundError`` stays on the unexpected-error path."""
|
|
|
|
async def _raise(_auth):
|
|
raise FileNotFoundError("missing source-file payload")
|
|
|
|
cli = _build_cli(_raise)
|
|
result = runner.invoke(cli, ["run", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 2, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "UNEXPECTED_ERROR"
|
|
|
|
|
|
def test_unexpected_error_json_payload_exits_2(runner: CliRunner, stubbed_auth) -> None:
|
|
"""JSON mode: unknown ``RuntimeError`` → UNEXPECTED_ERROR + exit 2 (system error)."""
|
|
|
|
async def _raise(_auth):
|
|
raise RuntimeError("something went sideways")
|
|
|
|
cli = _build_cli(_raise)
|
|
result = runner.invoke(cli, ["run", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 2, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "UNEXPECTED_ERROR"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backward-compat: happy path still works
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_successful_command_exits_zero(runner: CliRunner, stubbed_auth) -> None:
|
|
"""A command that runs cleanly should still exit 0 with no errors."""
|
|
sentinel: dict = {}
|
|
|
|
async def _ok(auth):
|
|
sentinel["called"] = True
|
|
click.echo("ok")
|
|
return None
|
|
|
|
cli = _build_cli(_ok)
|
|
result = runner.invoke(cli, ["run"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 0, result.stderr
|
|
assert sentinel.get("called") is True
|
|
assert "ok" in result.stdout
|
|
|
|
|
|
def test_successful_command_json_mode_exits_zero(runner: CliRunner, stubbed_auth) -> None:
|
|
"""``--json`` happy path also exits 0."""
|
|
|
|
async def _ok(auth):
|
|
click.echo(json.dumps({"ok": True}))
|
|
|
|
cli = _build_cli(_ok)
|
|
result = runner.invoke(cli, ["run", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 0, result.stderr
|
|
payload = json.loads(result.stdout)
|
|
assert payload == {"ok": True}
|