Files
teng-lin--notebooklm-py/tests/unit/test_with_client_handle_errors.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

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}