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
1407 lines
54 KiB
Python
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
|