Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

1407 lines
54 KiB
Python

"""Tests for notebook CLI commands (now top-level commands)."""
import json
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
import notebooklm.cli.context as context_module
import notebooklm.cli.helpers as helpers_module
import notebooklm.cli.notebook_cmd as notebook_cmd_module
import notebooklm.cli.resolve as resolve_module
from notebooklm.exceptions import NotebookLimitError, RPCError
from notebooklm.notebooklm_cli import cli
from notebooklm.rpc import RPCMethod
from notebooklm.types import AskResult, Notebook
from .conftest import (
create_mock_client,
inject_client,
research_start,
research_task,
)
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_auth():
with patch.object(helpers_module, "load_auth_from_storage") as mock:
mock.return_value = {
"SID": "test",
"HSID": "test",
"SSID": "test",
"APISID": "test",
"SAPISID": "test",
}
yield mock
# =============================================================================
# NOTEBOOK LIST TESTS
# =============================================================================
class TestNotebookList:
def test_notebook_list_empty(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(return_value=[])
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["list"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "Notebooks" in result.output
def test_notebook_list_with_notebooks(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_1",
title="First Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
Notebook(
id="nb_2",
title="Second Notebook",
created_at=datetime(2024, 1, 2),
is_owner=False,
),
]
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["list"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "Notebooks" in result.output
assert "nb_1" in result.output
assert "First Notebook" in result.output
assert "Second Notebook" in result.output
def test_notebook_list_json_output(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_1",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["list", "--json"], obj=inject_client(mock_client))
assert result.exit_code == 0
data = json.loads(result.output)
assert list(data) == ["notebooks", "count"]
assert "notebooks" in data
assert data["count"] == 1
assert list(data["notebooks"][0]) == [
"index",
"id",
"title",
"is_owner",
"created_at",
"modified_at",
]
assert data["notebooks"][0]["id"] == "nb_1"
def test_notebook_list_limit_caps_rows(self, runner, mock_auth):
"""`--limit N` returns at most N data rows in text output."""
many = [
Notebook(
id=f"nb_{i:02d}",
title=f"Notebook {i:02d}",
created_at=datetime(2024, 1, 1),
is_owner=True,
)
for i in range(25)
]
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(return_value=many)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["list", "--limit", "5"], obj=inject_client(mock_client))
assert result.exit_code == 0, result.output
# The first 5 notebook ids (zero-padded) should appear; later ones must not.
for i in range(5):
assert f"nb_{i:02d}" in result.output
for i in range(5, 25):
assert f"nb_{i:02d}" not in result.output
def test_notebook_list_limit_json_caps_rows(self, runner, mock_auth):
"""`--limit N` also caps the JSON `notebooks` array."""
many = [
Notebook(
id=f"nb_{i:02d}",
title=f"Notebook {i:02d}",
created_at=datetime(2024, 1, 1),
is_owner=True,
)
for i in range(25)
]
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(return_value=many)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["list", "--limit", "3", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["count"] == 3
assert len(data["notebooks"]) == 3
assert [n["id"] for n in data["notebooks"]] == ["nb_00", "nb_01", "nb_02"]
def test_notebook_list_no_truncate_disables_ellipsis(self, runner, mock_auth):
"""`--no-truncate` renders the full title without an ellipsis.
The default Title column uses Rich's ``overflow="ellipsis"`` so a
title that exceeds the auto-detected terminal width is truncated
with ``…``. ``--no-truncate`` flips the column to ``overflow="fold"``
so the title wraps instead, preserving every character.
"""
long_title = "X" * 200
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_long",
title=long_title,
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["list", "--no-truncate"], obj=inject_client(mock_client))
assert result.exit_code == 0, result.output
# Rich may soft-wrap the cell across many lines, but
# --no-truncate MUST preserve every character of the title and
# MUST NOT insert an ellipsis.
assert result.output.count("X") >= 200
assert "…" not in result.output
def test_notebook_list_default_truncates_long_title(self, runner, mock_auth, narrow_console):
"""Default rendering inserts an ellipsis for over-wide titles.
Pins the existing default behavior so --no-truncate doesn't change
rendering when the flag is not passed.
"""
long_title = "X" * 200
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_long",
title=long_title,
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["list"], obj=inject_client(mock_client))
assert result.exit_code == 0, result.output
# Default truncation should drop characters and add ellipsis.
assert result.output.count("X") < 200
assert "…" in result.output
# =============================================================================
# NOTEBOOK CREATE TESTS
# =============================================================================
class TestNotebookCreate:
def test_notebook_create(self, runner, mock_auth, mock_context_file):
"""Default create stays pure — context file is not touched."""
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(
return_value=Notebook(
id="new_nb_id", title="Test Notebook", created_at=datetime(2024, 1, 1)
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["create", "Test Notebook"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "Created notebook" in result.output
assert "--use" in result.output # hint shown when flag is omitted
assert not mock_context_file.exists()
def test_notebook_create_does_not_overwrite_existing_context(
self, runner, mock_auth, mock_context_file
):
"""Default create must leave a previously active context untouched."""
mock_context_file.write_text(
json.dumps({"notebook_id": "old_nb", "title": "Previously Active"})
)
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(
return_value=Notebook(id="new_nb_id", title="Fresh", created_at=datetime(2024, 1, 1))
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["create", "Fresh"], obj=inject_client(mock_client))
assert result.exit_code == 0
context = json.loads(mock_context_file.read_text())
assert context["notebook_id"] == "old_nb"
def test_notebook_create_json_output(self, runner, mock_auth, mock_context_file):
"""JSON mode without --use is pure — no context mutation, no hint noise."""
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(
return_value=Notebook(
id="new_nb_id", title="Test Notebook", created_at=datetime(2024, 1, 1)
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["create", "Test Notebook", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["notebook"]["id"] == "new_nb_id"
assert not mock_context_file.exists()
def test_notebook_create_with_use_flag(self, runner, mock_auth, mock_context_file):
"""`create --use` switches context (text mode)."""
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(
return_value=Notebook(
id="new_nb_id", title="Test Notebook", created_at=datetime(2024, 1, 1)
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["create", "Test Notebook", "--use"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "Context set to new notebook" in result.output
context = json.loads(mock_context_file.read_text())
assert context["notebook_id"] == "new_nb_id"
assert context["title"] == "Test Notebook"
def test_notebook_create_with_use_short_flag(self, runner, mock_auth, mock_context_file):
"""`-u` shorthand works identically to `--use`."""
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(
return_value=Notebook(id="short_id", title="Short", created_at=datetime(2024, 1, 1))
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["create", "Short", "-u"], obj=inject_client(mock_client))
assert result.exit_code == 0
context = json.loads(mock_context_file.read_text())
assert context["notebook_id"] == "short_id"
def test_notebook_create_with_use_json(self, runner, mock_auth, mock_context_file):
"""`create --use --json` switches context AND emits JSON (consistent with text mode).
the JSON envelope MUST surface the
`active_notebook_id` alongside the create result so script callers
can pick up the new context without scraping any "Context set to ..."
text or shelling out to `notebooklm status --json`.
"""
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(
return_value=Notebook(
id="new_nb_id", title="Test Notebook", created_at=datetime(2024, 1, 1)
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["create", "Test Notebook", "--use", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["notebook"]["id"] == "new_nb_id"
# When --use is set, the JSON envelope surfaces the new
# active notebook id so callers don't have to round-trip via
# `notebooklm status --json`.
assert data["active_notebook_id"] == "new_nb_id"
context = json.loads(mock_context_file.read_text())
assert context["notebook_id"] == "new_nb_id"
def test_notebook_create_json_without_use_omits_active_id(
self, runner, mock_auth, mock_context_file
):
"""`create --json` (no --use) MUST NOT emit `active_notebook_id` —
signals that context was not switched, so callers can branch on
presence/absence without parsing prose.
"""
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(
return_value=Notebook(
id="new_nb_id", title="Test Notebook", created_at=datetime(2024, 1, 1)
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["create", "Test Notebook", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["notebook"]["id"] == "new_nb_id"
assert "active_notebook_id" not in data
assert not mock_context_file.exists()
def test_notebook_create_json_quota_error(self, runner, mock_auth):
"""Create emits structured JSON when notebook quota is detected."""
mock_client = create_mock_client()
original = RPCError(
"RPC CCqFvf returned null result with status code 3 (Invalid argument).",
method_id=RPCMethod.CREATE_NOTEBOOK.value,
rpc_code=3,
)
mock_client.notebooks.create = AsyncMock(
side_effect=NotebookLimitError(499, limit=500, original_error=original)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["create", "Test Notebook", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 1
data = json.loads(result.output)
assert data["code"] == "NOTEBOOK_LIMIT"
assert data["current_count"] == 499
assert data["limit"] == 500
assert "known_limits" not in data
assert data["method_id"] == RPCMethod.CREATE_NOTEBOOK.value
assert data["rpc_code"] == 3
def test_notebook_create_text_quota_error(self, runner, mock_auth):
"""Create emits an actionable text error when notebook quota is detected."""
mock_client = create_mock_client()
mock_client.notebooks.create = AsyncMock(side_effect=NotebookLimitError(499, limit=500))
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["create", "Test Notebook"], obj=inject_client(mock_client))
assert result.exit_code == 1
assert "notebook limit" in result.output.lower()
assert "499/500" in result.output
# =============================================================================
# NOTEBOOK DELETE TESTS
# =============================================================================
class TestNotebookDelete:
def test_notebook_delete(self, runner, mock_auth):
mock_client = create_mock_client()
# Mock list for partial ID resolution (returns the notebook to be deleted)
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_to_delete",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.delete = AsyncMock(return_value=True)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["delete", "-n", "nb_to_delete", "-y"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "Deleted notebook" in result.output
mock_client.notebooks.delete.assert_called_once_with("nb_to_delete")
def test_notebook_delete_cancelled(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_to_delete",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.delete = AsyncMock(return_value=True)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["delete", "-n", "nb_to_delete"], input="n\n", obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "Delete notebook nb_to_delete?" in result.output
mock_client.notebooks.delete.assert_not_called()
def test_notebook_delete_clears_context_if_current(self, runner, mock_auth, tmp_path):
context_file = tmp_path / "context.json"
context_file.write_text('{"notebook_id": "nb_to_delete"}')
mock_client = create_mock_client()
# Mock list for partial ID resolution
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_to_delete",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.delete = AsyncMock(return_value=True)
with (
patch.object(helpers_module, "get_context_path", return_value=context_file),
patch.object(context_module, "get_context_path", return_value=context_file),
patch.object(resolve_module, "get_context_path", return_value=context_file),
patch.object(notebook_cmd_module, "get_current_notebook", return_value="nb_to_delete"),
patch.object(notebook_cmd_module, "clear_context"),
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["delete", "-n", "nb_to_delete", "-y"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "Cleared current notebook context" in result.output
def test_notebook_delete_failure(self, runner, mock_auth):
"""A real delete failure now raises (v0.7.0): delete() returns None and
propagates RPC/transport errors instead of signalling failure via a
falsy return (issue #1211)."""
mock_client = create_mock_client()
# Mock list for partial ID resolution
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.delete = AsyncMock(side_effect=RPCError("delete blew up"))
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["delete", "-n", "nb_123", "-y"], obj=inject_client(mock_client)
)
assert result.exit_code == 1
def test_notebook_delete_json(self, runner, mock_auth):
"""--json with --yes emits a parseable success envelope (issue #1167)."""
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_to_delete",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.delete = AsyncMock(return_value=True)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["delete", "-n", "nb_to_delete", "--json", "-y"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload == {"notebook_id": "nb_to_delete", "success": True}
mock_client.notebooks.delete.assert_called_once_with("nb_to_delete")
def test_notebook_delete_json_requires_yes(self, runner, mock_auth):
"""--json without --yes refuses to prompt and emits a typed error (issue #1167)."""
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_to_delete",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.delete = AsyncMock(return_value=True)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["delete", "-n", "nb_to_delete", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 1
payload = json.loads(result.output)
assert payload["error"] is True
assert payload["code"] == "VALIDATION_ERROR"
assert payload["notebook_id"] == "nb_to_delete"
assert payload["success"] is False
mock_client.notebooks.delete.assert_not_called()
def test_notebook_delete_json_clears_context_if_current(self, runner, mock_auth, tmp_path):
"""--json delete of the current notebook clears context and reports it (issue #1167)."""
context_file = tmp_path / "context.json"
context_file.write_text('{"notebook_id": "nb_to_delete"}')
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_to_delete",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.delete = AsyncMock(return_value=True)
with (
patch.object(helpers_module, "get_context_path", return_value=context_file),
patch.object(context_module, "get_context_path", return_value=context_file),
patch.object(resolve_module, "get_context_path", return_value=context_file),
patch.object(notebook_cmd_module, "get_current_notebook", return_value="nb_to_delete"),
patch.object(notebook_cmd_module, "clear_context") as mock_clear,
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["delete", "-n", "nb_to_delete", "--json", "-y"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload == {
"notebook_id": "nb_to_delete",
"success": True,
"context_cleared": True,
}
mock_clear.assert_called_once()
# =============================================================================
# NOTEBOOK RENAME TESTS
# =============================================================================
class TestNotebookRename:
def test_notebook_rename(self, runner, mock_auth):
mock_client = create_mock_client()
# Mock list for partial ID resolution
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.rename = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["rename", "New Title", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "Renamed notebook" in result.output
mock_client.notebooks.rename.assert_called_once_with("nb_123", "New Title")
def test_notebook_rename_json(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.rename = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["rename", "New Title", "-n", "nb_123", "--json"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload == {
"notebook_id": "nb_123",
"title": "New Title",
"success": True,
}
mock_client.notebooks.rename.assert_called_once_with("nb_123", "New Title")
# =============================================================================
# NOTEBOOK SHARE TESTS (moved to share command group)
# =============================================================================
# Note: Share functionality has moved to 'share' command group.
# Tests are now in tests/unit/cli/test_share.py
# =============================================================================
# NOTEBOOK SUMMARY TESTS
# =============================================================================
class TestNotebookSummary:
def test_notebook_summary(self, runner, mock_auth):
mock_client = create_mock_client()
# Mock list for partial ID resolution
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_desc = MagicMock()
mock_desc.summary = "This notebook contains research about AI."
mock_desc.suggested_topics = []
mock_client.notebooks.get_description = AsyncMock(return_value=mock_desc)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["summary", "-n", "nb_123"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "Summary" in result.output
assert "research about AI" in result.output
def test_notebook_summary_with_topics(self, runner, mock_auth):
mock_client = create_mock_client()
# Mock list for partial ID resolution
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_desc = MagicMock()
mock_desc.summary = "This is a summary."
mock_topic = MagicMock()
mock_topic.question = "What is machine learning?"
mock_desc.suggested_topics = [mock_topic]
mock_client.notebooks.get_description = AsyncMock(return_value=mock_desc)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["summary", "-n", "nb_123", "--topics"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "Suggested Topics" in result.output
assert "machine learning" in result.output
def test_notebook_summary_json(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_desc = MagicMock()
mock_desc.summary = "This is a summary."
mock_topic = MagicMock()
mock_topic.question = "What is machine learning?"
mock_desc.suggested_topics = [mock_topic]
mock_client.notebooks.get_description = AsyncMock(return_value=mock_desc)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["summary", "-n", "nb_123", "--topics", "--json"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload == {
"notebook_id": "nb_123",
"summary": "This is a summary.",
"suggested_topics": ["What is machine learning?"],
}
def test_notebook_summary_json_without_topics(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_desc = MagicMock()
mock_desc.summary = "This is a summary."
mock_topic = MagicMock()
mock_topic.question = "What is machine learning?"
mock_desc.suggested_topics = [mock_topic]
mock_client.notebooks.get_description = AsyncMock(return_value=mock_desc)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["summary", "-n", "nb_123", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
payload = json.loads(result.output)
# Without --topics the envelope stays lean: the key is omitted entirely
# (not an empty list) so callers branch on its presence.
assert payload == {"notebook_id": "nb_123", "summary": "This is a summary."}
assert "suggested_topics" not in payload
def test_notebook_summary_not_available(self, runner, mock_auth):
mock_client = create_mock_client()
# Mock list for partial ID resolution
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="nb_123",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_client.notebooks.get_description = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["summary", "-n", "nb_123"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "No summary available" in result.output
# =============================================================================
# NOTEBOOK HISTORY TESTS
# =============================================================================
class TestNotebookHistory:
def test_notebook_history(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.chat.get_history = AsyncMock(return_value=[("Q1?", "A1"), ("Q2?", "A2")])
mock_client.chat.get_conversation_id = AsyncMock(return_value="conv_001")
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["history", "-n", "nb_123"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "Conversation History" in result.output
def test_notebook_history_empty(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
mock_client.chat.get_history = AsyncMock(return_value=[])
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["history", "-n", "nb_123"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "No conversation history" in result.output
def test_notebook_history_clear_cache(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.chat.clear_cache = MagicMock(return_value=True)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["history", "--clear"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "cache cleared" in result.output
# =============================================================================
# NOTEBOOK ASK TESTS
# =============================================================================
class TestNotebookAsk:
def test_notebook_ask(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.chat.ask = AsyncMock(
return_value=AskResult(
answer="This is the answer to your question.",
conversation_id="conv_123",
is_follow_up=False,
turn_number=1,
)
)
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
with (
patch.object(
helpers_module,
"get_context_path",
return_value=Path("/nonexistent/context.json"),
),
patch.object(
context_module,
"get_context_path",
return_value=Path("/nonexistent/context.json"),
),
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["ask", "-n", "nb_123", "What is this?"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "This is the answer" in result.output
def test_notebook_ask_continue_conversation(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.chat.ask = AsyncMock(
return_value=AskResult(
answer="Follow-up answer",
conversation_id="conv_123",
is_follow_up=True,
turn_number=2,
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["ask", "-n", "nb_123", "-c", "conv_123", "Follow-up"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "Follow-up answer" in result.output
# =============================================================================
# NOTEBOOK CONFIGURE TESTS
# =============================================================================
class TestNotebookConfigure:
def test_notebook_configure_mode(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.chat.set_mode = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["configure", "-n", "nb_123", "--mode", "learning-guide"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "Chat mode set to: learning-guide" in result.output
def test_notebook_configure_persona(self, runner, mock_auth):
from notebooklm.types import ChatGoal, ChatResponseLength, ChatSettings
mock_client = create_mock_client()
mock_client.chat.configure = AsyncMock(return_value=None)
# persona-only is a partial merge → the core reads current settings first.
mock_client.chat.get_settings = AsyncMock(
return_value=ChatSettings(
goal=ChatGoal.DEFAULT,
response_length=ChatResponseLength.DEFAULT,
custom_prompt=None,
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["configure", "-n", "nb_123", "--persona", "Act as a tutor"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "Chat configured" in result.output
assert "persona" in result.output
def test_notebook_configure_response_length(self, runner, mock_auth):
from notebooklm.types import ChatGoal, ChatResponseLength, ChatSettings
mock_client = create_mock_client()
mock_client.chat.configure = AsyncMock(return_value=None)
# response-length-only is a partial merge → the core reads current settings first.
mock_client.chat.get_settings = AsyncMock(
return_value=ChatSettings(
goal=ChatGoal.DEFAULT,
response_length=ChatResponseLength.DEFAULT,
custom_prompt=None,
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["configure", "-n", "nb_123", "--response-length", "longer"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "response length: longer" in result.output
# =============================================================================
# SOURCE ADD-RESEARCH TESTS (moved from insights to source)
# =============================================================================
class TestSourceAddResearch:
def test_source_add_research_success(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.research.start = AsyncMock(return_value=research_start({"task_id": "task_123"}))
mock_client.research.poll = AsyncMock(
return_value=research_task({"status": "completed", "sources": [{"title": "Source 1"}]})
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["source", "add-research", "AI research", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "Found 1 sources" in result.output
def test_source_add_research_failed_to_start(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.research.start = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["source", "add-research", "AI research", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 1
assert "Research failed to start" in result.output
def test_source_add_research_with_import(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.research.start = AsyncMock(return_value=research_start({"task_id": "task_123"}))
mock_client.research.poll = AsyncMock(
return_value=research_task({"status": "completed", "sources": [{"id": "src_1"}]})
)
# CLI's import_with_retry now delegates to the library's
# import_sources_with_verification method (#315).
mock_client.research.import_sources_with_verification = AsyncMock(
return_value=[{"id": "src_1"}]
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["source", "add-research", "AI research", "-n", "nb_123", "--import-all"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "Imported 1 sources" in result.output
# =============================================================================
# COMMAND EXISTENCE TESTS
# =============================================================================
class TestNotebookCommandsExist:
def test_list_command_exists(self, runner):
result = runner.invoke(cli, ["list", "--help"])
assert result.exit_code == 0
assert "List all notebooks" in result.output
def test_create_command_exists(self, runner):
result = runner.invoke(cli, ["create", "--help"])
assert result.exit_code == 0
assert "TITLE" in result.output
def test_delete_command_exists(self, runner):
result = runner.invoke(cli, ["delete", "--help"])
assert result.exit_code == 0
assert "Delete a notebook" in result.output
def test_rename_command_exists(self, runner):
result = runner.invoke(cli, ["rename", "--help"])
assert result.exit_code == 0
assert "Rename a notebook" in result.output
def test_ask_command_exists(self, runner):
result = runner.invoke(cli, ["ask", "--help"])
assert result.exit_code == 0
assert "QUESTION" in result.output
def test_top_level_help_shows_notebook_commands(self, runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
# Verify notebook commands are at top level
assert "list" in result.output
assert "create" in result.output
assert "delete" in result.output
assert "ask" in result.output
# Verify there's no "notebook" command in the Commands section
# (it should only appear as part of "NotebookLM" in the description)
commands_section = (
result.output.split("Commands:")[1] if "Commands:" in result.output else ""
)
assert " notebook " not in commands_section.lower()
# =============================================================================
# METADATA COMMAND TESTS
# =============================================================================
class TestNotebookMetadata:
"""Tests for the metadata command."""
def test_metadata_human_readable(self, runner, mock_auth):
"""Test human-readable output (default)."""
from datetime import datetime
from notebooklm.types import Notebook, NotebookMetadata, SourceSummary, SourceType
mock_client = create_mock_client()
notebook = Notebook(
id="nb_1",
title="Test Notebook",
created_at=datetime(2024, 1, 1),
)
# Override notebooks.list to return only our test notebook (avoid partial ID conflicts)
mock_client.notebooks.list = AsyncMock(return_value=[notebook])
metadata = NotebookMetadata(
notebook=notebook,
sources=[
SourceSummary(kind=SourceType.PDF, title="test.pdf"),
],
)
# Use side_effect to avoid potential pickling issues with enums
async def return_metadata(nb_id):
return metadata
mock_client.notebooks.get_metadata = AsyncMock(side_effect=return_metadata)
mock_client.notebooks.get = AsyncMock(return_value=notebook)
with (
patch.object(
resolve_module.context_helpers,
"get_current_notebook",
return_value="nb_1",
),
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["metadata"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "Test Notebook" in result.output
assert "[pdf]" in result.output
assert "nb_1" in result.output
def test_metadata_json_output(self, runner, mock_auth):
"""Test JSON output with --json flag."""
from notebooklm.types import Notebook, NotebookMetadata, SourceSummary, SourceType
mock_client = create_mock_client()
notebook = Notebook(id="nb_1", title="Test Notebook")
# Override notebooks.list to return only our test notebook
mock_client.notebooks.list = AsyncMock(return_value=[notebook])
metadata = NotebookMetadata(
notebook=notebook,
sources=[SourceSummary(kind=SourceType.PDF, title="test.pdf")],
)
# Use side_effect to avoid potential pickling issues with enums
async def return_metadata(nb_id):
return metadata
mock_client.notebooks.get_metadata = AsyncMock(side_effect=return_metadata)
mock_client.notebooks.get = AsyncMock(return_value=notebook)
with (
patch.object(
resolve_module.context_helpers,
"get_current_notebook",
return_value="nb_1",
),
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["metadata", "--json"], obj=inject_client(mock_client))
assert result.exit_code == 0
# JSON output should be valid JSON (without Rich markup in JSON mode)
data = json.loads(result.output)
assert data["id"] == "nb_1"
assert data["title"] == "Test Notebook"
assert data["sources"][0]["type"] == "pdf"
def test_metadata_empty_sources(self, runner, mock_auth):
"""Test metadata with no sources."""
from notebooklm.types import Notebook, NotebookMetadata
mock_client = create_mock_client()
notebook = Notebook(id="nb_empty", title="Empty Notebook")
# Override notebooks.list to return only our test notebook
mock_client.notebooks.list = AsyncMock(return_value=[notebook])
metadata = NotebookMetadata(notebook=notebook, sources=[])
mock_client.notebooks.get_metadata = AsyncMock(return_value=metadata)
mock_client.notebooks.get = AsyncMock(return_value=notebook)
with (
patch.object(
resolve_module.context_helpers,
"get_current_notebook",
return_value="nb_empty",
),
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["metadata"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "No sources" in result.output
def test_metadata_with_url_source(self, runner, mock_auth):
"""Test metadata with URL source displays URL."""
from notebooklm.types import Notebook, NotebookMetadata, SourceSummary, SourceType
mock_client = create_mock_client()
notebook = Notebook(id="nb_url", title="URL Notebook")
# Override notebooks.list to return only our test notebook
mock_client.notebooks.list = AsyncMock(return_value=[notebook])
metadata = NotebookMetadata(
notebook=notebook,
sources=[
SourceSummary(
kind=SourceType.WEB_PAGE,
title="Example Site",
url="https://example.com/article",
)
],
)
mock_client.notebooks.get_metadata = AsyncMock(return_value=metadata)
mock_client.notebooks.get = AsyncMock(return_value=notebook)
with (
patch.object(
resolve_module.context_helpers,
"get_current_notebook",
return_value="nb_url",
),
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, ["metadata"], obj=inject_client(mock_client))
assert result.exit_code == 0
output_lines = result.output.splitlines()
assert " https://example.com/article" in output_lines