"""Tests for artifact CLI commands.""" import importlib import json from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from click.testing import CliRunner import notebooklm.auth as auth_module from notebooklm.cli import helpers as helpers_module from notebooklm.notebooklm_cli import cli from notebooklm.types import Artifact, GenerationStatus, MindMap, MindMapKind from .conftest import create_mock_client, inject_client # ``notebooklm.cli.artifact_cmd`` (the module) is shadowed by ``cli.__init__``'s # re-export of the ``artifact`` Click Group (same name). Use ``importlib`` so # tests target the module's attribute set (``console``, helpers) rather than # the Click Group sitting at the same dotted path. artifact_module = importlib.import_module("notebooklm.cli.artifact_cmd") @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 # ============================================================================= # ARTIFACT LIST TESTS # ============================================================================= class TestArtifactList: @pytest.mark.filterwarnings("ignore::notebooklm.types.UnknownTypeWarning") def test_artifact_list(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[ Artifact(id="art_1", title="Quiz One", _artifact_type=4, status=3), Artifact(id="art_2", title="Briefing Doc", _artifact_type=2, status=3), ] ) mock_client.notes.list_mind_maps = 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, ["artifact", "list", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "Artifacts in nb_123" in result.output assert "art_1" in result.output assert "Quiz One" in result.output def test_artifact_list_includes_mind_maps(self, runner, mock_auth): """Test that artifacts.list() includes mind maps (they come from the API now).""" mock_client = create_mock_client() # Mind maps are now included via artifacts.list() from the notes system mock_client.artifacts.list = AsyncMock( return_value=[ Artifact(id="mm_1", title="My Mind Map", _artifact_type=5, status=3), ] ) 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, ["artifact", "list", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "Mind Map" in result.output def test_artifact_list_json_output(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[ Artifact(id="art_1", title="Test Artifact", _artifact_type=4, status=3), ] ) mock_client.notes.list_mind_maps = AsyncMock(return_value=[]) mock_client.notebooks.get = AsyncMock(return_value=MagicMock(title="Test Notebook")) 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, ["artifact", "list", "-n", "nb_123", "--json"], obj=inject_client(mock_client) ) assert result.exit_code == 0 data = json.loads(result.output) assert list(data) == ["notebook_id", "notebook_title", "artifacts", "count"] assert data["notebook_id"] == "nb_123" assert data["notebook_title"] == "Test Notebook" assert "artifacts" in data assert data["count"] == 1 assert list(data["artifacts"][0]) == [ "index", "id", "title", "type", "type_id", "status", "status_id", "created_at", ] @pytest.mark.filterwarnings("ignore::notebooklm.types.UnknownTypeWarning") def test_artifact_list_limit_caps_rows(self, runner, mock_auth): """`artifact list --limit N` returns at most N data rows.""" many = [ Artifact(id=f"art_{i:02d}", title=f"Artifact {i:02d}", _artifact_type=4, status=3) for i in range(15) ] mock_client = create_mock_client() mock_client.artifacts.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, ["artifact", "list", "-n", "nb_123", "--limit", "3"], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output for i in range(3): assert f"art_{i:02d}" in result.output for i in range(3, 15): assert f"art_{i:02d}" not in result.output @pytest.mark.filterwarnings("ignore::notebooklm.types.UnknownTypeWarning") def test_artifact_list_limit_json_caps_rows(self, runner, mock_auth): """`artifact list --limit N --json` caps the JSON `artifacts` array.""" many = [ Artifact(id=f"art_{i:02d}", title=f"Artifact {i:02d}", _artifact_type=4, status=3) for i in range(15) ] mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock(return_value=many) mock_client.notebooks.get = AsyncMock(return_value=MagicMock(title="Test")) 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, ["artifact", "list", "-n", "nb_123", "--limit", "2", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["count"] == 2 assert len(data["artifacts"]) == 2 assert [a["id"] for a in data["artifacts"]] == ["art_00", "art_01"] def test_artifact_list_no_truncate_disables_ellipsis(self, runner, mock_auth): """`artifact list --no-truncate` shows full title without 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() # _artifact_type=1 (AUDIO) avoids the QUIZ/FLASHCARDS variant # disambiguation so the Type column stays narrow ("🎧 Audio") # and never injects an ellipsis on its own. mock_client.artifacts.list = AsyncMock( return_value=[ Artifact(id="art_long", title=long_title, _artifact_type=1, status=3), ] ) 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, ["artifact", "list", "-n", "nb_123", "--no-truncate"], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output assert result.output.count("X") >= 200 assert "…" not in result.output def test_artifact_list_default_truncates_long_title(self, runner, mock_auth, narrow_console): """Default rendering inserts an ellipsis for over-wide titles.""" long_title = "X" * 200 mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[ Artifact(id="art_long", title=long_title, _artifact_type=1, status=3), ] ) 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, ["artifact", "list", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0, result.output assert result.output.count("X") < 200 assert "…" in result.output def test_artifact_list_json_missing_notebook_emits_not_found(self, runner, mock_auth): """A ``NotebookNotFoundError`` reaching the central handler maps to NOT_FOUND. End-to-end coverage for issue #1364 via a direct raise-site: ``artifact list --json`` calls ``client.notebooks.get`` to build the envelope title, and ``notebooks.get`` raises ``NotebookNotFoundError`` on a missing notebook (it always raises — not gated by ``NOTEBOOKLM_FUTURE_ERRORS``). The central handler must emit the typed ``NOT_FOUND`` envelope rather than the generic ``NOTEBOOKLM_ERROR``. """ from notebooklm.exceptions import NotebookNotFoundError mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock(return_value=[]) mock_client.notes.list_mind_maps = AsyncMock(return_value=[]) mock_client.notebooks.get = AsyncMock(side_effect=NotebookNotFoundError("nb_123")) 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, ["artifact", "list", "-n", "nb_123", "--json"], obj=inject_client(mock_client) ) assert result.exit_code == 1 data = json.loads(result.output) assert data["error"] is True assert data["code"] == "NOT_FOUND" assert data["notebook_id"] == "nb_123" assert data["id"] == "nb_123" def test_artifact_list_future_errors_missing_notebook_emits_not_found( self, runner, mock_auth, monkeypatch ): """The ``NOTEBOOKLM_FUTURE_ERRORS`` preview is faithful at the CLI boundary. With the v0.8.0 error-contract preview enabled, the same missing-notebook path still yields the typed ``NOT_FOUND`` envelope (code + exit 1) — confirming the central handler already routes the previewed raise-sites correctly so the v0.8.0 flips land with zero CLI scramble (issue #1364). """ from notebooklm.exceptions import NotebookNotFoundError monkeypatch.setenv("NOTEBOOKLM_FUTURE_ERRORS", "1") mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock(return_value=[]) mock_client.notes.list_mind_maps = AsyncMock(return_value=[]) mock_client.notebooks.get = AsyncMock(side_effect=NotebookNotFoundError("nb_123")) 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, ["artifact", "list", "-n", "nb_123", "--json"], obj=inject_client(mock_client) ) assert result.exit_code == 1 data = json.loads(result.output) assert data["code"] == "NOT_FOUND" assert data["notebook_id"] == "nb_123" # ============================================================================= # ARTIFACT GET TESTS # ============================================================================= class TestArtifactGet: def test_artifact_get(self, runner, mock_auth): mock_client = create_mock_client() # Mock list for partial ID resolution mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.artifacts.get_or_none = AsyncMock( return_value=Artifact( id="art_123", title="Test Artifact", _artifact_type=4, status=3, 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, ["artifact", "get", "art_123", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "Test Artifact" in result.output assert "art_123" in result.output def test_artifact_get_not_found(self, runner, mock_auth): mock_client = create_mock_client() # Mock list to return empty (no match for resolve_artifact_id) mock_client.artifacts.list = AsyncMock(return_value=[]) mock_client.artifacts.get_or_none = 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, ["artifact", "get", "nonexistent", "-n", "nb_123"], obj=inject_client(mock_client), ) # Now exits with error from resolve_artifact_id (no match) assert result.exit_code == 1 assert "No artifact found" in result.output def test_artifact_get_json_output(self, runner, mock_auth): """`artifact get --json` emits structured JSON mirroring the Artifact.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.artifacts.get_or_none = AsyncMock( return_value=Artifact( id="art_123", title="Test Artifact", _artifact_type=4, status=3, 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, ["artifact", "get", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["id"] == "art_123" assert data["title"] == "Test Artifact" assert data["found"] is True # notebook_id mirrors the wrapper used by `artifact list --json`, # so cached responses share one schema across the two commands. assert data["notebook_id"] == "nb_123" # type / status / created_at keys must be present for automation assert "type" in data assert "status" in data assert "created_at" in data def test_artifact_get_json_not_found(self, runner, mock_auth): """`artifact get --json` emits typed JSON error + exits 1. ``client.artifacts.get`` may return ``None`` after a successful partial-ID resolve when the server reports the artifact has been deleted between the list call and the get call. The current contract emits the standard typed JSON error envelope (``{error, code, message}``) + exit 1 (changed from the previous exit-0 ``{found: false}`` placeholder). See ``docs/cli-exit-codes.md`` and the BREAKING entry in ``CHANGELOG.md``. """ mock_client = create_mock_client() # Resolve succeeds (list contains the ID) but get() returns None # (e.g., concurrent delete from another session). mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Doomed", _artifact_type=4, status=3)] ) mock_client.artifacts.get_or_none = 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, ["artifact", "get", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 1, result.output data = json.loads(result.output) assert data["error"] is True assert data["code"] == "NOT_FOUND" assert "Artifact not found" in data["message"] assert data["id"] == "art_123" assert data["notebook_id"] == "nb_123" # ------------------------------------------------------------------------- # get-on-not-found exits 1 (changed from 0). Mirrors the # ``test_source.py`` Path A / Path B coverage so the contract on the two # ``get`` commands matches. # ------------------------------------------------------------------------- def test_artifact_get_not_found_pathA_long_id_text_exits_1(self, runner, mock_auth): """Path A: UUID-shaped ID skips partial-resolve; backend None → exit 1.""" # Canonical 36-char UUID — qualifies for the resolver's full-ID fast-path # so artifacts.list is bypassed and the backend ``get`` is hit directly. long_id = "abc12345-6789-4abc-def0-1234567890ab" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock(return_value=[]) mock_client.artifacts.get_or_none = 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, ["artifact", "get", long_id, "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 1, result.output assert "Artifact not found" in result.output mock_client.artifacts.list.assert_not_called() def test_artifact_get_not_found_pathA_long_id_json_exits_1(self, runner, mock_auth): """Path A under ``--json``: typed JSON error doc + exit 1.""" long_id = "abc12345-6789-4abc-def0-1234567890ab" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock(return_value=[]) mock_client.artifacts.get_or_none = 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, ["artifact", "get", long_id, "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 1, result.output data = json.loads(result.output) assert data["error"] is True assert data["code"] == "NOT_FOUND" assert "Artifact not found" in data["message"] assert data["id"] == long_id assert data["notebook_id"] == "nb_123" mock_client.artifacts.list.assert_not_called() def test_artifact_get_not_found_pathB_resolved_then_none_text_exits_1(self, runner, mock_auth): """Path B: partial-resolve succeeds, backend get() returns None → exit 1.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_xyz", title="Doomed", _artifact_type=4, status=3)] ) mock_client.artifacts.get_or_none = 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, ["artifact", "get", "art_xyz", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 1, result.output assert "Artifact not found" in result.output # ============================================================================= # ARTIFACT GET-PROMPT TESTS # ============================================================================= class TestArtifactGetPrompt: def test_artifact_get_prompt(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.artifacts.get_prompt = AsyncMock(return_value="Explain the technique.") 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, ["artifact", "get-prompt", "art_123", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Explain the technique." in result.output def test_artifact_get_prompt_json(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.artifacts.get_prompt = AsyncMock(return_value="Explain the technique.") 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, ["artifact", "get-prompt", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["id"] == "art_123" assert data["notebook_id"] == "nb_123" assert data["prompt"] == "Explain the technique." def test_artifact_get_prompt_none_message(self, runner, mock_auth): """An artifact with no stored prompt prints a friendly notice, exit 0.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Note Mind Map", _artifact_type=5, status=3)] ) mock_client.artifacts.get_prompt = 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, ["artifact", "get-prompt", "art_123", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "no stored prompt" in result.output def test_artifact_get_prompt_not_found(self, runner, mock_auth): """A partial id that resolves to nothing exits 1 via resolve_artifact_id.""" mock_client = create_mock_client() mock_client.artifacts.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, ["artifact", "get-prompt", "nonexistent", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 1 assert "No artifact found" in result.output # ============================================================================= # ARTIFACT RENAME TESTS # ============================================================================= class TestArtifactRename: def test_artifact_rename(self, runner, mock_auth): mock_client = create_mock_client() # Mock list for partial ID resolution mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Old Title", _artifact_type=4, status=3)] ) mock_client.notes.list_mind_maps = AsyncMock(return_value=[]) mock_client.artifacts.rename = AsyncMock( return_value=Artifact(id="art_123", title="New Title", _artifact_type=4, status=3) ) 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, ["artifact", "rename", "art_123", "New Title", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Renamed artifact" in result.output def test_artifact_rename_json_output(self, runner, mock_auth): """`artifact rename --json` emits a structured success payload.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Old Title", _artifact_type=4, status=3)] ) mock_client.notes.list_mind_maps = AsyncMock(return_value=[]) mock_client.artifacts.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, ["artifact", "rename", "art_123", "New Title", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data == {"id": "art_123", "renamed": True, "new_title": "New Title"} @pytest.mark.parametrize("map_kind_attr", ["NOTE_BACKED", "INTERACTIVE"]) def test_artifact_rename_dispatches_mind_map(self, runner, mock_auth, map_kind_attr): """A mind-map id is renamed via the unified API (kind-aware), not blocked. Both kinds must route through ``mind_maps.rename`` carrying the map's own ``kind`` (not a hardcoded one), and the regular-artifact ``artifacts.rename`` path must stay unused for mind maps — so a regression that pinned one kind or also called ``artifacts.rename`` would be caught here. """ map_kind = getattr(MindMapKind, map_kind_attr) mock_client = create_mock_client() # Mock list for partial ID resolution (include the mind map) mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="mm_123", title="Old Title", _artifact_type=5, status=3)] ) mock_client.mind_maps.list = AsyncMock( return_value=[ MindMap( id="mm_123", notebook_id="nb_123", title="Old Title", kind=map_kind, ) ] ) mock_client.mind_maps.rename = AsyncMock() mock_client.artifacts.rename = AsyncMock() 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, ["artifact", "rename", "mm_123", "New Title", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Renamed" in result.output # The CLI ignores the rename return, so it passes return_object=False # to skip the (unused) hydrate re-fetch. mock_client.mind_maps.rename.assert_awaited_once_with( "nb_123", "mm_123", "New Title", kind=map_kind, return_object=False ) # Mind maps never fall through to the regular-artifact rename path. mock_client.artifacts.rename.assert_not_called() def test_artifact_rename_missing_target_emits_not_found(self, runner, mock_auth): """A ``*NotFoundError`` from ``rename`` reaches the central handler as NOT_FOUND. End-to-end coverage for issue #1364: the v0.7.0+ ``rename`` raise-site (mutate-existing fail-loud, #1362) surfaces ``ArtifactNotFoundError`` when the target is absent — e.g. deleted between the partial-id resolve and the rename. It must emit the typed ``NOT_FOUND`` envelope, not the generic ``NOTEBOOKLM_ERROR``. """ from notebooklm.exceptions import ArtifactNotFoundError mock_client = create_mock_client() # ``list`` resolves the partial id (proves it existed at resolve # time); ``rename`` then races a delete and raises. mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Old Title", _artifact_type=4, status=3)] ) mock_client.mind_maps.list = AsyncMock(return_value=[]) mock_client.artifacts.rename = AsyncMock( side_effect=ArtifactNotFoundError("art_123", "audio") ) 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, ["artifact", "rename", "art_123", "New Title", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 1 data = json.loads(result.output) assert data["error"] is True assert data["code"] == "NOT_FOUND" assert data["artifact_id"] == "art_123" assert data["id"] == "art_123" # ============================================================================= # ARTIFACT DELETE TESTS # ============================================================================= class TestArtifactDelete: def test_artifact_delete(self, runner, mock_auth): mock_client = create_mock_client() # Mock list for partial ID resolution mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[]) mock_client.artifacts.delete = 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, ["artifact", "delete", "art_123", "-n", "nb_123", "-y"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Deleted artifact" in result.output def test_artifact_delete_cancelled(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[]) mock_client.artifacts.delete = 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, ["artifact", "delete", "art_123", "-n", "nb_123"], input="n\n", obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Delete artifact art_123?" in result.output mock_client.artifacts.delete.assert_not_called() def test_artifact_delete_json_output(self, runner, mock_auth): """`artifact delete --json` emits a structured success payload.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[]) mock_client.artifacts.delete = 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, ["artifact", "delete", "art_123", "-n", "nb_123", "-y", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data == {"id": "art_123", "deleted": True} def test_artifact_delete_json_without_yes_emits_structured_error_no_prompt( self, runner, mock_auth ): """`artifact delete --json` without `--yes` refuses instead of deleting.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test Artifact", _artifact_type=4, status=3)] ) mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[]) mock_client.artifacts.delete = AsyncMock(return_value=None) with ( patch.object( auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock ) as mock_fetch, patch("click.confirm") as mock_confirm, ): mock_fetch.return_value = ("csrf", "session") result = runner.invoke( cli, ["artifact", "delete", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 1 data = json.loads(result.output) assert data["error"] is True assert data["code"] == "VALIDATION_ERROR" assert "--yes" in data["message"] assert data["id"] == "art_123" assert data["notebook_id"] == "nb_123" assert data["deleted"] is False mock_confirm.assert_not_called() mock_client.artifacts.delete.assert_not_called() def test_artifact_delete_mind_map_json_output(self, runner, mock_auth): """`artifact delete --json` flags mind-map carve-out in the payload.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="mm_456", title="Mind Map Title", _artifact_type=5, status=3)] ) mock_client.mind_maps.list_note_backed = AsyncMock( return_value=[ MindMap( id="mm_456", notebook_id="nb_123", title="Mind Map Title", kind=MindMapKind.NOTE_BACKED, ) ] ) mock_client.notes.delete = 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, ["artifact", "delete", "mm_456", "-n", "nb_123", "-y", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["id"] == "mm_456" assert data["deleted"] is True assert data["kind"] == "mind_map" def test_artifact_delete_mind_map_clears(self, runner, mock_auth): mock_client = create_mock_client() # Mock list for partial ID resolution (include the mind map) mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="mm_456", title="Mind Map Title", _artifact_type=5, status=3)] ) mock_client.mind_maps.list_note_backed = AsyncMock( return_value=[ MindMap( id="mm_456", notebook_id="nb_123", title="Mind Map Title", kind=MindMapKind.NOTE_BACKED, ) ] ) mock_client.notes.delete = 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, ["artifact", "delete", "mm_456", "-n", "nb_123", "-y"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Cleared mind map" in result.output mock_client.notes.delete.assert_called_once_with("nb_123", "mm_456") # ============================================================================= # ARTIFACT EXPORT TESTS # ============================================================================= class TestArtifactExport: def test_artifact_export_docs(self, runner, mock_auth): mock_client = create_mock_client() # Mock list for partial ID resolution mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Doc", _artifact_type=2, status=3)] ) mock_client.artifacts.export = AsyncMock( return_value={"url": "https://docs.google.com/document/d/123"} ) 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, ["artifact", "export", "art_123", "--title", "My Export", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Exported to Google Docs" in result.output # Verify export was called with correct arguments mock_client.artifacts.export.assert_called_once() call_args = mock_client.artifacts.export.call_args from notebooklm.rpc import ExportType # call_args[0] = (notebook_id, artifact_id, title, export_type); content is # keyword-only and OMITTED entirely (not passed as an explicit None) so the # backend retrieves it from the artifact id. assert "content" not in call_args.kwargs, "content should be omitted (backend retrieves it)" assert call_args[0][3] == ExportType.DOCS, "export_type should be ExportType.DOCS" def test_artifact_export_sheets(self, runner, mock_auth): mock_client = create_mock_client() # Mock list for partial ID resolution mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Table", _artifact_type=9, status=3)] ) mock_client.artifacts.export = AsyncMock( return_value={"url": "https://sheets.google.com/spreadsheets/d/123"} ) 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, [ "artifact", "export", "art_123", "--title", "My Sheet", "--type", "sheets", "-n", "nb_123", ], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Exported to Google Sheets" in result.output # Verify export was called with correct arguments mock_client.artifacts.export.assert_called_once() call_args = mock_client.artifacts.export.call_args from notebooklm.rpc import ExportType # call_args[0] = (notebook_id, artifact_id, title, export_type); content is # keyword-only and OMITTED entirely (not passed as an explicit None) so the # backend retrieves it from the artifact id. assert "content" not in call_args.kwargs, "content should be omitted (backend retrieves it)" assert call_args[0][3] == ExportType.SHEETS, "export_type should be ExportType.SHEETS" def test_artifact_export_json_output(self, runner, mock_auth): """`artifact export --json` emits structured payload with the export result.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Doc", _artifact_type=2, status=3)] ) mock_client.artifacts.export = AsyncMock( return_value={"url": "https://docs.google.com/document/d/123"} ) 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, [ "artifact", "export", "art_123", "--title", "My Export", "-n", "nb_123", "--json", ], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["id"] == "art_123" assert data["exported"] is True assert data["export_type"] == "docs" assert data["title"] == "My Export" assert data["result"] == {"url": "https://docs.google.com/document/d/123"} def test_artifact_export_failure(self, runner, mock_auth): mock_client = create_mock_client() # Mock list for partial ID resolution mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Doc", _artifact_type=2, status=3)] ) mock_client.artifacts.export = 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, ["artifact", "export", "art_123", "--title", "Fail", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Export may have failed" in result.output # ============================================================================= # ARTIFACT POLL TESTS # ============================================================================= class TestArtifactPoll: def test_artifact_poll(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.poll_status = AsyncMock( return_value={"status": "completed", "artifact_id": "art_123"} ) 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, ["artifact", "poll", "task_123", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Task Status" in result.output def test_artifact_poll_json_output(self, runner, mock_auth): """`artifact poll --json` mirrors the GenerationStatus dataclass fields.""" from notebooklm.types import GenerationStatus mock_client = create_mock_client() mock_client.artifacts.poll_status = AsyncMock( return_value=GenerationStatus( task_id="task_123", status="completed", url="https://example.com/audio.mp3", error=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, ["artifact", "poll", "task_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["task_id"] == "task_123" assert data["status"] == "completed" assert data["url"] == "https://example.com/audio.mp3" assert data["error"] is None # ============================================================================= # ARTIFACT WAIT TESTS # ============================================================================= class TestArtifactWait: def test_artifact_wait_completed(self, runner, mock_auth): """Test waiting for artifact that completes successfully.""" mock_client = create_mock_client() # Mock list for partial ID resolution mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=3)] ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=MagicMock( status="completed", url="https://example.com/audio.mp3", error=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, ["artifact", "wait", "art_123", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "Artifact completed" in result.output def test_artifact_wait_failed(self, runner, mock_auth): """Test waiting for artifact that fails generation.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=1)] ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=GenerationStatus( task_id="art_123", status="failed", url=None, error="Generation failed due to content policy", ) ) 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, ["artifact", "wait", "art_123", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 1 assert "Generation failed" in result.output def test_artifact_wait_timeout(self, runner, mock_auth): """Test waiting for artifact that times out.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=1)] ) mock_client.artifacts.wait_for_completion = AsyncMock(side_effect=TimeoutError("Timed out")) 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, ["artifact", "wait", "art_123", "-n", "nb_123", "--timeout", "5"], obj=inject_client(mock_client), ) assert result.exit_code == 1 assert "Timeout" in result.output def test_artifact_wait_json_output(self, runner, mock_auth): """Test waiting with JSON output.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=3)] ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=MagicMock( status="completed", url="https://example.com/audio.mp3", error=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, ["artifact", "wait", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "completed" assert data["artifact_id"] == "art_123" def test_artifact_wait_timeout_interval_forwarded(self, runner, mock_auth): """`artifact wait --timeout 60 --interval 5` plumbs both into wait_for_completion. Pre-existing behavior — already had `--timeout` and `--interval` — but pin the wiring so the shared `wait_polling_options` decorator cannot silently drop one of the values during the refactor. """ mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=3)] ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=MagicMock( status="completed", url="https://example.com/audio.mp3", error=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, [ "artifact", "wait", "art_123", "-n", "nb_123", "--timeout", "60", "--interval", "5", ], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output mock_client.artifacts.wait_for_completion.assert_awaited_once() kwargs = mock_client.artifacts.wait_for_completion.await_args.kwargs assert kwargs.get("timeout") == 60.0 assert kwargs.get("initial_interval") == 5.0, ( f"expected --interval=5 to plumb into wait_for_completion, got kwargs={kwargs}" ) assert "poll_interval" not in kwargs def test_artifact_wait_timeout_json_output(self, runner, mock_auth): """Test timeout with JSON output.""" mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=1)] ) mock_client.artifacts.wait_for_completion = AsyncMock(side_effect=TimeoutError("Timed out")) 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, ["artifact", "wait", "art_123", "-n", "nb_123", "--json", "--timeout", "5"], obj=inject_client(mock_client), ) assert result.exit_code == 1 data = json.loads(result.output) assert data["status"] == "timeout" def test_artifact_wait_invokes_console_status(self, runner, mock_auth): """`artifact wait` wraps the polling call in `console.status`. The spinner gives interactive users feedback during the blocking wait. Asserts the wrap by patching `notebooklm.cli.artifact_cmd.console.status` and confirming it is invoked exactly once with a message that mentions the wait. Does not assert under `--json` because the JSON path intentionally suppresses the spinner to keep stdout pure JSON. """ mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=3)] ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=MagicMock( status="completed", url="https://example.com/audio.mp3", error=None ) ) with ( patch.object( auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock ) as mock_fetch, patch.object(artifact_module.console, "status") as mock_status, ): mock_fetch.return_value = ("csrf", "session") mock_status.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_status.return_value.__exit__ = MagicMock(return_value=False) result = runner.invoke( cli, ["artifact", "wait", "art_123", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0, result.output assert mock_status.called, "expected console.status to wrap the wait call" status_msg = mock_status.call_args.args[0] assert "artifact" in status_msg.lower() or "wait" in status_msg.lower(), ( f"expected status message to describe the wait, got: {status_msg!r}" ) def test_artifact_wait_json_skips_console_status(self, runner, mock_auth): """`artifact wait --json` must NOT invoke console.status (stdout stays JSON). The spinner is suppressed under JSON mode so automation parsing stdout does not see Rich escape sequences leak in. """ mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=3)] ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=MagicMock( status="completed", url="https://example.com/audio.mp3", error=None ) ) with ( patch.object( auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock ) as mock_fetch, patch.object(artifact_module.console, "status") as mock_status, ): mock_fetch.return_value = ("csrf", "session") result = runner.invoke( cli, ["artifact", "wait", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output assert not mock_status.called, ( "console.status must NOT be invoked under --json (would leak ANSI into stdout)" ) def test_artifact_wait_sigint_prints_resume_hint_and_exits_130(self, runner, mock_auth): """Ctrl-C during ``artifact wait`` exits 130 with the canonical resume hint naming the resolved artifact id. Same hint shape as ``generate --wait`` because both polling loops resume via ``artifact poll``. Simulates the Ctrl-C by raising ``KeyboardInterrupt`` from the awaitable that the wait loop is currently suspended on. """ mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_sigint", title="Test", _artifact_type=1, status=3)] ) mock_client.artifacts.wait_for_completion = AsyncMock(side_effect=KeyboardInterrupt) 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, ["artifact", "wait", "art_sigint", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 130, ( f"expected SIGINT exit 130, got {result.exit_code}; output={result.output!r}" ) combined = result.output + (result.stderr if result.stderr_bytes else "") assert "Cancelled. Resume with: notebooklm artifact poll art_sigint" in combined, ( f"expected canonical resume hint with artifact id; got: {combined!r}" ) def test_artifact_wait_sigint_json_emits_cancelled_envelope(self, runner, mock_auth): """Ctrl-C under ``artifact wait --json`` emits a CANCELLED envelope with the resume hint, exits 130. Keeps stdout-as-JSON automation from breaking on a Python traceback. """ mock_client = create_mock_client() mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_json_sigint", title="T", _artifact_type=1, status=3)] ) mock_client.artifacts.wait_for_completion = AsyncMock(side_effect=KeyboardInterrupt) 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, ["artifact", "wait", "art_json_sigint", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 130 assert '"code": "CANCELLED"' in result.output assert "notebooklm artifact poll art_json_sigint" in result.output # ============================================================================= # ARTIFACT RETRY TESTS # ============================================================================= class TestArtifactRetry: def _client_with_failed_artifact(self): mock_client = create_mock_client() # Partial-id resolution lists once and matches art_123. mock_client.artifacts.list = AsyncMock( return_value=[Artifact(id="art_123", title="Test", _artifact_type=1, status=4)] ) return mock_client def test_artifact_retry_starts_without_wait(self, runner, mock_auth): from notebooklm.types import GenerationStatus mock_client = self._client_with_failed_artifact() mock_client.artifacts.retry_failed = AsyncMock( return_value=GenerationStatus(task_id="art_123", status="in_progress") ) 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, ["artifact", "retry", "art_123", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Retry started" in result.output mock_client.artifacts.retry_failed.assert_awaited_once_with("nb_123", "art_123") mock_client.artifacts.wait_for_completion.assert_not_called() def test_artifact_retry_json_output(self, runner, mock_auth): from notebooklm.types import GenerationStatus mock_client = self._client_with_failed_artifact() mock_client.artifacts.retry_failed = AsyncMock( return_value=GenerationStatus(task_id="art_123", status="in_progress") ) 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, ["artifact", "retry", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data["task_id"] == "art_123" assert data["status"] == "in_progress" def test_artifact_retry_wait_blocks_until_complete(self, runner, mock_auth): from notebooklm.types import GenerationStatus mock_client = self._client_with_failed_artifact() mock_client.artifacts.retry_failed = AsyncMock( return_value=GenerationStatus(task_id="art_123", status="in_progress") ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=MagicMock( task_id="art_123", status="completed", url="https://example.com/video.mp4", error=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, ["artifact", "retry", "art_123", "-n", "nb_123", "--wait"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Artifact completed" in result.output mock_client.artifacts.wait_for_completion.assert_awaited_once() def test_artifact_retry_wait_terminal_failure_exits_nonzero_text_mode(self, runner, mock_auth): """A provider-side retry that fails again (terminal `failed`, even with no extractable error string) exits non-zero in text mode — not reported as a successful command.""" from notebooklm.types import GenerationStatus mock_client = self._client_with_failed_artifact() mock_client.artifacts.retry_failed = AsyncMock( return_value=GenerationStatus(task_id="art_123", status="in_progress") ) # Terminal failure with error=None — exercises the text `else` branch. mock_client.artifacts.wait_for_completion = AsyncMock( return_value=GenerationStatus(task_id="art_123", status="failed", error=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, ["artifact", "retry", "art_123", "-n", "nb_123", "--wait"], obj=inject_client(mock_client), ) assert result.exit_code == 1 assert "Artifact completed" not in result.output def test_artifact_retry_wait_json_terminal_failure_exits_nonzero(self, runner, mock_auth): """`--wait --json` with a terminal failure exits 1 with a JSON payload keyed by `artifact_id` (matching `artifact wait`).""" from notebooklm.types import GenerationStatus mock_client = self._client_with_failed_artifact() mock_client.artifacts.retry_failed = AsyncMock( return_value=GenerationStatus(task_id="art_123", status="in_progress") ) mock_client.artifacts.wait_for_completion = AsyncMock( return_value=GenerationStatus( task_id="art_123", status="failed", error="Provider error" ) ) 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, ["artifact", "retry", "art_123", "-n", "nb_123", "--wait", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 1 data = json.loads(result.output) assert data["status"] == "failed" assert data["artifact_id"] == "art_123" def test_artifact_retry_wait_timeout_json_output(self, runner, mock_auth): """Timeout with `--wait --json` emits a structured payload (keyed by `artifact_id`, matching `artifact wait`) and exits 1.""" from notebooklm.types import GenerationStatus mock_client = self._client_with_failed_artifact() mock_client.artifacts.retry_failed = AsyncMock( return_value=GenerationStatus(task_id="art_123", status="in_progress") ) mock_client.artifacts.wait_for_completion = AsyncMock(side_effect=TimeoutError("Timed out")) 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, ["artifact", "retry", "art_123", "-n", "nb_123", "--wait", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 1 data = json.loads(result.output) assert data["status"] == "timeout" assert data["artifact_id"] == "art_123" def test_artifact_retry_refusal_exits_nonzero(self, runner, mock_auth): """A synchronous RateLimitError refusal surfaces as a CLI error, not a started task — verifies retry_failed's raise propagates through the centralized CLI error handler.""" from notebooklm.exceptions import RateLimitError mock_client = self._client_with_failed_artifact() mock_client.artifacts.retry_failed = AsyncMock( side_effect=RateLimitError("Rate limit exceeded", rpc_code="USER_DISPLAYABLE_ERROR") ) 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, ["artifact", "retry", "art_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code != 0 assert "Retry started" not in result.output # ============================================================================= # ARTIFACT SUGGESTIONS TESTS # ============================================================================= class TestArtifactSuggestions: def test_artifact_suggestions(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.suggest_reports = AsyncMock( return_value=[ MagicMock(title="Topic 1", description="Desc 1", prompt="Prompt 1"), MagicMock(title="Topic 2", description="Desc 2", prompt="Prompt 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, ["artifact", "suggestions", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "Suggested Reports" in result.output def test_artifact_suggestions_empty(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.suggest_reports = 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, ["artifact", "suggestions", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "No suggestions available" in result.output def test_artifact_suggestions_json(self, runner, mock_auth): mock_client = create_mock_client() mock_client.artifacts.suggest_reports = AsyncMock( return_value=[ MagicMock(title="Topic 1", description="Desc 1", prompt="Prompt 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, ["artifact", "suggestions", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 assert data[0]["title"] == "Topic 1" def test_artifact_suggestions_json_preserves_unicode(self, runner, mock_auth): """CJK / emoji in suggestion titles should be emitted as real UTF-8.""" mock_client = create_mock_client() mock_client.artifacts.suggest_reports = AsyncMock( return_value=[ MagicMock(title="中文主题 🚀", description="说明", prompt="问题"), ] ) 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, ["artifact", "suggestions", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0 data = json.loads(result.output) assert data[0]["title"] == "中文主题 🚀" assert data[0]["description"] == "说明" assert data[0]["prompt"] == "问题" # Raw output must contain real CJK/emoji, not escaped sequences. assert "中文主题" in result.output assert "🚀" in result.output assert "\\u" not in result.output # ============================================================================= # COMMAND EXISTENCE TESTS # ============================================================================= class TestArtifactCommandsExist: def test_artifact_group_exists(self, runner): result = runner.invoke(cli, ["artifact", "--help"]) assert result.exit_code == 0 assert "list" in result.output assert "get" in result.output assert "get-prompt" in result.output assert "delete" in result.output assert "wait" in result.output def test_artifact_get_prompt_command_exists(self, runner): result = runner.invoke(cli, ["artifact", "get-prompt", "--help"]) assert result.exit_code == 0 assert "generation prompt" in result.output def test_artifact_list_command_exists(self, runner): result = runner.invoke(cli, ["artifact", "list", "--help"]) assert result.exit_code == 0 assert "--type" in result.output def test_artifact_wait_command_exists(self, runner): result = runner.invoke(cli, ["artifact", "wait", "--help"]) assert result.exit_code == 0 assert "--timeout" in result.output assert "--interval" in result.output assert "--json" in result.output # ============================================================================= # POLL vs WAIT ID-KIND DOCUMENTATION SNAPSHOT # ============================================================================= class TestPollWaitIdKindHelp: """Snapshot test that pins the canonical ID-source phrasing in help text. `artifact poll` and `artifact wait` both accept the same underlying identifier (the API returns one ID that serves as both the generation task_id and the eventual artifact_id; see ``_artifacts.py`` ``_parse_generation_result`` docstring). The two commands diverge in: 1. **Resolution**: ``poll`` passes the raw ID through (so it works *immediately* after ``generate `` returns, before the artifact appears in any list); ``wait`` partial-matches against ``artifact list`` output via ``resolve_artifact_id``. 2. **Blocking**: ``poll`` is a single non-blocking check; ``wait`` blocks with ``--timeout`` / ``--interval`` exponential backoff. These tests assert the help docstrings explicitly cite where each ID typically comes from so users stop confusing them. If a future docstring refactor removes the canonical phrasing, these snapshots fail loudly. """ # Canonical phrases — pinned here as the source of truth. POLL_PHRASE_TASK_ID_FROM_GENERATE = "task_id" POLL_PHRASE_GENERATE_REFERENCE = "generate" WAIT_PHRASE_ARTIFACT_LIST_REFERENCE = "artifact list" SHARED_PHRASE_SAME_ID = "same identifier" def test_poll_help_cites_task_id_source(self, runner): """`artifact poll --help` must explain the task_id comes from `generate`.""" result = runner.invoke(cli, ["artifact", "poll", "--help"]) assert result.exit_code == 0 # Must reference the task_id concept and where it comes from. assert self.POLL_PHRASE_TASK_ID_FROM_GENERATE in result.output, ( "poll --help must reference 'task_id' as the ID kind" ) assert self.POLL_PHRASE_GENERATE_REFERENCE in result.output, ( "poll --help must reference `generate` as the source of the ID" ) def test_wait_help_cites_artifact_list_source(self, runner): """`artifact wait --help` must explain the artifact_id comes from `artifact list`.""" result = runner.invoke(cli, ["artifact", "wait", "--help"]) assert result.exit_code == 0 # Must reference the artifact_id discovery path. assert self.WAIT_PHRASE_ARTIFACT_LIST_REFERENCE in result.output, ( "wait --help must reference `artifact list` as the source of the ID" ) def test_both_help_acknowledge_same_underlying_id(self, runner): """Both `--help` outputs should acknowledge that the underlying ID is the same. This prevents users from believing they need to convert between two distinct ID kinds when copy-pasting between `poll` and `wait`. """ poll_result = runner.invoke(cli, ["artifact", "poll", "--help"]) wait_result = runner.invoke(cli, ["artifact", "wait", "--help"]) assert poll_result.exit_code == 0 assert wait_result.exit_code == 0 assert self.SHARED_PHRASE_SAME_ID in poll_result.output, ( "poll --help must acknowledge poll/wait share the same identifier" ) assert self.SHARED_PHRASE_SAME_ID in wait_result.output, ( "wait --help must acknowledge poll/wait share the same identifier" ) def test_poll_help_includes_concrete_example(self, runner): """poll --help should include a runnable example.""" result = runner.invoke(cli, ["artifact", "poll", "--help"]) assert result.exit_code == 0 assert "notebooklm artifact poll" in result.output, ( "poll --help must include at least one concrete example invocation" ) def test_wait_help_includes_concrete_example(self, runner): """wait --help should retain the existing concrete examples.""" result = runner.invoke(cli, ["artifact", "wait", "--help"]) assert result.exit_code == 0 assert "notebooklm artifact wait" in result.output, ( "wait --help must include at least one concrete example invocation" )