09e9f3545f
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
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
376 lines
16 KiB
Python
376 lines
16 KiB
Python
"""Tests for the ``notebooklm use`` command (context-setting + auth-aware errors).
|
|
|
|
This file was extracted from the legacy ``test_session.py`` as part of
|
|
D1 PR-3 (test-monkeypatch-policy migration). The shared fixtures and
|
|
helpers live in ``_session_helpers.py``; the proxy-block-aware
|
|
``patch_session_login_dual`` lives in ``tests/_fixtures``.
|
|
"""
|
|
|
|
import inspect
|
|
import json
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from click.testing import CliRunner
|
|
|
|
import notebooklm.auth as auth_module
|
|
import notebooklm.cli.helpers as helpers_module
|
|
import notebooklm.cli.session_cmd as session_cmd_module
|
|
from notebooklm.notebooklm_cli import cli
|
|
from notebooklm.types import Notebook
|
|
|
|
from .conftest import create_mock_client, inject_client
|
|
|
|
|
|
def _split_stream_runner() -> CliRunner:
|
|
"""Return a CliRunner whose stdout and stderr are captured separately.
|
|
|
|
The shared ``runner`` fixture in ``conftest.py`` uses ``CliRunner()`` with
|
|
default settings, which on Click 8.1.x means ``mix_stderr=True`` —
|
|
``result.stdout`` then contains a mixed stream and ``result.stderr``
|
|
raises ``ValueError("stderr not separately captured")``. The
|
|
``--json`` purity test needs pure-stdout / pure-stderr access to verify
|
|
that a diagnostic does not leak into stdout. Click 8.2+ removed
|
|
``mix_stderr`` entirely (streams are always separate); 8.1.x supports
|
|
``mix_stderr=False``. Detect via ``inspect.signature`` so this is
|
|
portable across the project's supported Click range (``>=8.0.0,<9``).
|
|
"""
|
|
if "mix_stderr" in inspect.signature(CliRunner).parameters:
|
|
return CliRunner(mix_stderr=False)
|
|
return CliRunner()
|
|
|
|
|
|
class TestUseCommand:
|
|
def test_use_sets_notebook_context(self, runner, mock_auth, mock_context_file):
|
|
"""Test 'use' command sets the current notebook context."""
|
|
mock_client = create_mock_client()
|
|
mock_client.notebooks.get = AsyncMock(
|
|
return_value=Notebook(
|
|
id="nb_123",
|
|
title="Test Notebook",
|
|
created_at=datetime(2024, 1, 15),
|
|
is_owner=True,
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
|
|
# Patch in session module where it's imported
|
|
with patch.object(
|
|
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
|
|
) as mock_resolve:
|
|
mock_resolve.return_value = "nb_123"
|
|
|
|
result = runner.invoke(cli, ["use", "nb_123"], obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
assert "nb_123" in result.output or "Test Notebook" in result.output
|
|
|
|
def test_use_with_partial_id(self, runner, mock_auth, mock_context_file):
|
|
"""Test 'use' command resolves partial notebook ID."""
|
|
mock_client = create_mock_client()
|
|
mock_client.notebooks.get = AsyncMock(
|
|
return_value=Notebook(
|
|
id="nb_full_id_123",
|
|
title="Resolved Notebook",
|
|
created_at=datetime(2024, 1, 15),
|
|
is_owner=True,
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
|
|
# Patch in session module where it's imported
|
|
with patch.object(
|
|
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
|
|
) as mock_resolve:
|
|
mock_resolve.return_value = "nb_full_id_123"
|
|
|
|
result = runner.invoke(cli, ["use", "nb_full"], obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
# Should show resolved full ID
|
|
assert "nb_full_id_123" in result.output or "Resolved Notebook" in result.output
|
|
|
|
def test_use_without_auth_fails_closed(self, runner, mock_context_file):
|
|
"""'use' fails closed (exit 1) when no auth is available.
|
|
|
|
Previously, behavior persisted unverified IDs after auth failure, poisoning
|
|
saved state for downstream commands. The new contract: refuse to write
|
|
context.json and emit a clear "run notebooklm login" message.
|
|
"""
|
|
with patch.object(
|
|
helpers_module,
|
|
"load_auth_from_storage",
|
|
side_effect=FileNotFoundError("No auth"),
|
|
):
|
|
result = runner.invoke(cli, ["use", "nb_noauth"])
|
|
|
|
# Refuses to persist; surfaces a remediation hint.
|
|
assert result.exit_code == 1
|
|
assert not mock_context_file.exists()
|
|
assert (
|
|
"notebooklm login" in result.output.lower()
|
|
or "authentication" in result.output.lower()
|
|
or "--force" in result.output
|
|
)
|
|
|
|
def test_use_without_auth_force_persists(self, runner, mock_context_file):
|
|
"""`use --force` bypasses verification, mirrors offline/debug path."""
|
|
with patch.object(
|
|
helpers_module,
|
|
"load_auth_from_storage",
|
|
side_effect=FileNotFoundError("No auth"),
|
|
):
|
|
result = runner.invoke(cli, ["use", "--force", "nb_forced"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "nb_forced" in result.output
|
|
assert mock_context_file.exists()
|
|
data = json.loads(mock_context_file.read_text())
|
|
assert data["notebook_id"] == "nb_forced"
|
|
|
|
def test_use_shows_owner_status(self, runner, mock_auth, mock_context_file):
|
|
"""Test 'use' command displays ownership status correctly."""
|
|
mock_client = create_mock_client()
|
|
mock_client.notebooks.get = AsyncMock(
|
|
return_value=Notebook(
|
|
id="nb_shared",
|
|
title="Shared Notebook",
|
|
created_at=datetime(2024, 1, 15),
|
|
is_owner=False, # Shared notebook
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
|
|
# Patch in session module where it's imported
|
|
with patch.object(
|
|
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
|
|
) as mock_resolve:
|
|
mock_resolve.return_value = "nb_shared"
|
|
|
|
result = runner.invoke(cli, ["use", "nb_shared"], obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
assert "Shared" in result.output or "nb_shared" in result.output
|
|
|
|
|
|
# =============================================================================
|
|
# USE COMMAND --json + auth-aware errors
|
|
# =============================================================================
|
|
|
|
|
|
class TestUseJsonOutput:
|
|
"""`notebooklm use <id> --json` emits a structured envelope with the new
|
|
active notebook id so script and AI-agent automation does not have
|
|
to scrape the rendered Rich table for the next-step ID.
|
|
"""
|
|
|
|
def test_use_json_emits_active_notebook_id(self, runner, mock_auth, mock_context_file):
|
|
"""`use <id> --json` prints `{"active_notebook_id": "...", "success": true, ...}`."""
|
|
mock_client = create_mock_client()
|
|
mock_client.notebooks.get = AsyncMock(
|
|
return_value=Notebook(
|
|
id="nb_json_use",
|
|
title="Use JSON",
|
|
created_at=datetime(2026, 5, 14),
|
|
is_owner=True,
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
with patch.object(
|
|
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
|
|
) as mock_resolve:
|
|
mock_resolve.return_value = "nb_json_use"
|
|
|
|
result = runner.invoke(
|
|
cli, ["use", "nb_json_use", "--json"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
# Stable, scriptable contract: the new active notebook id is the
|
|
# primary signal; success boolean lets callers branch without
|
|
# parsing the body.
|
|
assert data["active_notebook_id"] == "nb_json_use"
|
|
assert data["success"] is True
|
|
# Notebook metadata is included so callers don't have to round-trip
|
|
# to `notebooklm list` to render a confirmation.
|
|
assert data["notebook"]["id"] == "nb_json_use"
|
|
assert data["notebook"]["title"] == "Use JSON"
|
|
# Context file was persisted as a side effect.
|
|
ctx = json.loads(mock_context_file.read_text())
|
|
assert ctx["notebook_id"] == "nb_json_use"
|
|
|
|
def test_use_json_with_force_emits_active_notebook_id(self, runner, mock_context_file):
|
|
"""`use --force --json` skips verification but still emits the JSON envelope."""
|
|
result = runner.invoke(cli, ["use", "--force", "nb_forced_json", "--json"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["active_notebook_id"] == "nb_forced_json"
|
|
assert data["success"] is True
|
|
# Mark verification status so script callers can detect unverified IDs.
|
|
assert data.get("verified") is False
|
|
assert mock_context_file.exists()
|
|
|
|
def test_use_json_with_partial_id_keeps_stdout_pure(self, mock_auth, mock_context_file):
|
|
"""`use <partial-id> --json` must NOT print the "Matched: ..." diagnostic to stdout.
|
|
|
|
Regression test for the bug where the `use` command called
|
|
``resolve_notebook_id(client, notebook_id)`` without forwarding
|
|
``json_output``, so the partial-ID-match "Matched: …" diagnostic
|
|
line went to stdout in JSON mode and broke ``json.loads`` for
|
|
scripted callers.
|
|
|
|
Contract: in `--json` mode, stdout MUST be parseable JSON. The
|
|
"Matched: …" diagnostic must route to stderr.
|
|
|
|
Uses ``_split_stream_runner()`` instead of the shared ``runner``
|
|
fixture because the shared one defaults to ``mix_stderr=True`` on
|
|
Click 8.1.x — that would (a) put the diagnostic into ``stdout``
|
|
making the bug invisible to this test, and (b) raise on
|
|
``result.stderr`` access. Project supports Click ``>=8.0.0,<9``.
|
|
"""
|
|
runner = _split_stream_runner()
|
|
full_id = "nb_partial_resolved_full_id"
|
|
mock_client = create_mock_client()
|
|
# Real resolver path: list() returns the candidates so the
|
|
# prefix-match branch fires and emits "Matched: …".
|
|
mock_client.notebooks.list = AsyncMock(
|
|
return_value=[
|
|
Notebook(
|
|
id=full_id,
|
|
title="Partial Match Notebook",
|
|
created_at=datetime(2026, 5, 21),
|
|
is_owner=True,
|
|
),
|
|
]
|
|
)
|
|
mock_client.notebooks.get = AsyncMock(
|
|
return_value=Notebook(
|
|
id=full_id,
|
|
title="Partial Match Notebook",
|
|
created_at=datetime(2026, 5, 21),
|
|
is_owner=True,
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
# NOTE: intentionally do NOT patch resolve_notebook_id —
|
|
# we want the real partial-ID resolver to run so we can
|
|
# verify it doesn't pollute stdout with "Matched: …".
|
|
result = runner.invoke(
|
|
cli, ["use", "nb_partial", "--json"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}"
|
|
# Hard contract: stdout (what `notebooklm use --json > out.json`
|
|
# captures) MUST be pure parseable JSON, regardless of what
|
|
# diagnostics get printed alongside on stderr.
|
|
data = json.loads(result.stdout)
|
|
assert data["active_notebook_id"] == full_id
|
|
assert data["success"] is True
|
|
# The diagnostic must route to stderr, not stdout.
|
|
assert "Matched:" not in result.stdout, (
|
|
f"`use --json` leaked partial-ID diagnostic to stdout: {result.stdout!r}"
|
|
)
|
|
# Sanity-check that the diagnostic DID run somewhere (otherwise
|
|
# this test could silently regress to "resolver didn't emit at
|
|
# all"). The "Matched: …" line should appear on stderr.
|
|
assert "Matched:" in result.stderr, (
|
|
f"resolver diagnostic missing from stderr — test setup may not "
|
|
f"be exercising the partial-ID match branch: stderr={result.stderr!r}"
|
|
)
|
|
|
|
|
|
class TestUseAuthAwareError:
|
|
"""When `notebooklm use <id>` hits an `AuthError` (e.g. expired SID
|
|
cookies), the catch must surface the typed "run notebooklm login" UX
|
|
from `helpers.handle_auth_error` rather than the generic "Could not
|
|
verify ... Pass --force" catch-all.
|
|
"""
|
|
|
|
def test_use_auth_error_suggests_notebooklm_login(self, runner, mock_auth, mock_context_file):
|
|
"""AuthError → text mode prints the typed login hint, exit 1, no persist."""
|
|
from notebooklm.exceptions import AuthError
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.notebooks.get = AsyncMock(
|
|
side_effect=AuthError("Auth expired", method_id="rwIQyf"),
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
with patch.object(
|
|
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
|
|
) as mock_resolve:
|
|
mock_resolve.return_value = "nb_auth_expired"
|
|
|
|
result = runner.invoke(
|
|
cli, ["use", "nb_auth_expired"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
# Fail-closed: do not poison context.json on auth expiry.
|
|
assert not mock_context_file.exists()
|
|
# The typed UX: explicit "notebooklm login" remediation.
|
|
assert "notebooklm login" in result.output.lower()
|
|
# The generic catch-all message must NOT be the one shown.
|
|
assert "Pass --force to persist without verification" not in result.output
|
|
|
|
def test_use_auth_error_json_emits_typed_envelope(self, runner, mock_auth, mock_context_file):
|
|
"""AuthError + --json → typed `{"code": "AUTH_REQUIRED", ...}` envelope, exit 1."""
|
|
from notebooklm.exceptions import AuthError
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.notebooks.get = AsyncMock(
|
|
side_effect=AuthError("Auth expired", method_id="rwIQyf"),
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
with patch.object(
|
|
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
|
|
) as mock_resolve:
|
|
mock_resolve.return_value = "nb_auth_expired"
|
|
|
|
result = runner.invoke(
|
|
cli, ["use", "nb_auth_expired", "--json"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert not mock_context_file.exists()
|
|
data = json.loads(result.output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "AUTH_REQUIRED"
|
|
assert (
|
|
"notebooklm login" in data["message"].lower() or "notebooklm login" in str(data).lower()
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# STATUS COMMAND TESTS
|
|
# =============================================================================
|