"""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