"""Tests for note CLI commands.""" import json from unittest.mock import AsyncMock, 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 Note from .conftest import create_mock_client, inject_client def make_note(id: str, title: str, content: str, notebook_id: str = "nb_123") -> Note: """Create a Note for testing.""" return Note( id=id, notebook_id=notebook_id, title=title, content=content, ) @pytest.fixture def runner(): """Provide a Click test runner.""" return CliRunner() @pytest.fixture def mock_auth(): """Patch auth storage to return test credentials.""" 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 # ============================================================================= # NOTE LIST TESTS # ============================================================================= class TestNoteList: """Tests for the note list command.""" def test_note_list(self, runner, mock_auth): """Renders a table when notes exist.""" mock_client = create_mock_client() mock_client.notes.list = AsyncMock( return_value=[ make_note("note_1", "Note Title", "Content 1"), make_note("note_2", "Another Note", "Content 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, ["note", "list", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "Notes in nb_123" in result.output assert "note_1" in result.output assert "Note Title" in result.output assert "Content 1" in result.output def test_note_list_empty(self, runner, mock_auth): """Shows 'No notes found' when the notebook has no notes.""" mock_client = create_mock_client() mock_client.notes.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, ["note", "list", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "No notes found" in result.output def test_note_list_json(self, runner, mock_auth): """Outputs valid JSON with notebook_id, notes array, and count.""" mock_client = create_mock_client() mock_client.notes.list = AsyncMock( return_value=[ make_note("note_1", "Note Title", "Content 1"), make_note("note_2", "Another Note", "Content 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, ["note", "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", "notes", "count"] assert data["notebook_id"] == "nb_123" assert len(data["notes"]) == 2 assert data["count"] == 2 assert list(data["notes"][0]) == ["id", "title", "preview"] assert data["notes"][0]["id"] == "note_1" def test_note_list_json_empty(self, runner, mock_auth): """JSON output has empty notes array and count of zero when no notes exist.""" mock_client = create_mock_client() mock_client.notes.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, ["note", "list", "-n", "nb_123", "--json"], obj=inject_client(mock_client) ) assert result.exit_code == 0 data = json.loads(result.output) assert data["notes"] == [] assert data["count"] == 0 def test_note_list_json_count_matches_serialized_notes(self, runner, mock_auth): """count reflects only Note instances, not total items in the raw list.""" mock_client = create_mock_client() # Include a non-Note item to verify count only counts Note instances mock_client.notes.list = AsyncMock( return_value=[ make_note("note_1", "Title", "Content"), "unexpected_string_item", # non-Note item ] ) 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, ["note", "list", "-n", "nb_123", "--json"], obj=inject_client(mock_client) ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data["notes"]) == 1 assert data["count"] == 1 # must match notes array length, not raw list length # ============================================================================= # NOTE CREATE TESTS # ============================================================================= class TestNoteCreate: """Tests for the note create command.""" def test_note_create(self, runner, mock_auth): """Creates a note and confirms success message.""" mock_client = create_mock_client() mock_client.notes.create = AsyncMock( return_value=make_note("note_new", "My Note", "Hello world") ) 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, ["note", "create", "Hello world", "--title", "My Note", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Note created" in result.output def test_note_create_empty(self, runner, mock_auth): """Creates an empty note with the default title.""" mock_client = create_mock_client() mock_client.notes.create = AsyncMock(return_value=make_note("note_new", "New Note", "")) 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, ["note", "create", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 # ============================================================================= # NOTE GET TESTS # ============================================================================= class TestNoteGet: """Tests for the note get command.""" def test_note_get(self, runner, mock_auth): """Displays note ID, title, and content.""" mock_client = create_mock_client() # Mock notes.list for resolve_note_id mock_client.notes.list = AsyncMock( return_value=[make_note("note_123", "My Note", "This is the content")] ) mock_client.notes.get_or_none = AsyncMock( return_value=make_note("note_123", "My Note", "This is the content") ) 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, ["note", "get", "note_123", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 0 assert "note_123" in result.output assert "This is the content" in result.output def test_note_get_not_found(self, runner, mock_auth): """Exits with error code 1 when no matching note exists.""" mock_client = create_mock_client() # Mock notes.list to return empty (no match for resolve_note_id) mock_client.notes.list = AsyncMock(return_value=[]) mock_client.notes.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, ["note", "get", "nonexistent", "-n", "nb_123"], obj=inject_client(mock_client) ) # resolve_note_id will raise ClickException for no match assert result.exit_code == 1 assert "No note found" in result.output # ------------------------------------------------------------------------- # get-on-not-found now exits 1 (was 0). Mirrors the # ``test_source.py`` / ``test_artifact.py`` Path A / Path B coverage so the # contract is uniform across all three ``get`` commands. # ------------------------------------------------------------------------- def test_note_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 — matches the resolver's full-ID fast-path so # notes.list is bypassed and the backend ``get`` is hit directly. long_id = "abc12345-6789-4abc-def0-1234567890ab" mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[]) mock_client.notes.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, ["note", "get", long_id, "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 1, result.output assert "Note not found" in result.output mock_client.notes.list.assert_not_called() def test_note_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.notes.list = AsyncMock(return_value=[]) mock_client.notes.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, ["note", "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 "Note not found" in data["message"] assert data["id"] == long_id assert data["notebook_id"] == "nb_123" mock_client.notes.list.assert_not_called() def test_note_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.notes.list = AsyncMock(return_value=[make_note("note_xyz", "Doomed", "")]) mock_client.notes.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, ["note", "get", "note_xyz", "-n", "nb_123"], obj=inject_client(mock_client) ) assert result.exit_code == 1, result.output assert "Note not found" in result.output # ============================================================================= # NOTE SAVE TESTS # ============================================================================= class TestNoteSave: """Tests for the note save command.""" def test_note_save_content(self, runner, mock_auth): """Updates note content and prints confirmation.""" mock_client = create_mock_client() # Mock notes.list for resolve_note_id mock_client.notes.list = AsyncMock( return_value=[make_note("note_123", "Test Note", "Original content")] ) mock_client.notes.update = 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, ["note", "save", "note_123", "--content", "New content", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Note updated" in result.output def test_note_save_title(self, runner, mock_auth): """Updates note title and prints confirmation.""" mock_client = create_mock_client() # Mock notes.list for resolve_note_id mock_client.notes.list = AsyncMock( return_value=[make_note("note_123", "Old Title", "Content")] ) mock_client.notes.update = 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, ["note", "save", "note_123", "--title", "New Title", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Note updated" in result.output def test_note_save_no_changes(self, runner, mock_auth): """Should show message when neither title nor content provided""" mock_client = create_mock_client() 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, ["note", "save", "note_123", "-n", "nb_123"], obj=inject_client(mock_client) ) assert "Provide --title and/or --content" in result.output # ============================================================================= # NOTE RENAME TESTS # ============================================================================= class TestNoteRename: """Tests for the note rename command.""" def test_note_rename(self, runner, mock_auth): """Renames a note and prints the new title.""" mock_client = create_mock_client() # Mock notes.list for resolve_note_id mock_client.notes.list = AsyncMock( return_value=[make_note("note_123", "Old Title", "Original content")] ) mock_client.notes.get_or_none = AsyncMock( return_value=make_note("note_123", "Old Title", "Original content") ) mock_client.notes.update = 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, ["note", "rename", "note_123", "New Title", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Note renamed" in result.output def test_note_rename_not_found(self, runner, mock_auth): """Exits with error code 1 when the note cannot be resolved.""" mock_client = create_mock_client() # Mock notes.list to return empty (no match for resolve_note_id) mock_client.notes.list = AsyncMock(return_value=[]) mock_client.notes.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, ["note", "rename", "nonexistent", "New Title", "-n", "nb_123"], obj=inject_client(mock_client), ) # resolve_note_id will raise ClickException for no match assert result.exit_code == 1 assert "No note found" in result.output # ============================================================================= # NOTE DELETE TESTS # ============================================================================= class TestNoteDelete: """Tests for the note delete command.""" def test_note_delete(self, runner, mock_auth): """Deletes a note and prints the deleted note ID.""" mock_client = create_mock_client() # Mock notes.list for resolve_note_id mock_client.notes.list = AsyncMock( return_value=[make_note("note_123", "Test Note", "Content")] ) 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, ["note", "delete", "note_123", "-n", "nb_123", "-y"], obj=inject_client(mock_client), ) assert result.exit_code == 0 assert "Deleted note" in result.output # ============================================================================= # COMMAND EXISTENCE TESTS # ============================================================================= class TestNoteCommandsExist: """Smoke tests that verify all note subcommands are registered.""" def test_note_group_exists(self, runner): """Note group help lists all expected subcommands.""" result = runner.invoke(cli, ["note", "--help"]) assert result.exit_code == 0 assert "list" in result.output assert "create" in result.output assert "rename" in result.output assert "delete" in result.output def test_note_create_command_exists(self, runner): """note create --help exposes --title and CONTENT argument.""" result = runner.invoke(cli, ["note", "create", "--help"]) assert result.exit_code == 0 assert "--title" in result.output assert "[CONTENT]" in result.output def test_note_list_json_flag_exists(self, runner): """note list --help exposes the --json flag.""" result = runner.invoke(cli, ["note", "list", "--help"]) assert result.exit_code == 0 assert "--json" in result.output # ============================================================================= # JSON OUTPUT TESTS # ============================================================================= # # Each mutating subcommand (create/save/rename/delete) emits a structured # ``{"id": ..., "ed": true}`` shape on success and ``{..., "error": ...}`` # on failure. ``note get`` mirrors the underlying ``Note`` dataclass via # ``dataclasses.asdict`` so callers can round-trip the value through automation # without needing to introspect Rich-formatted text. # # Smoke level only: each test asserts ``json.loads(stdout)`` is parseable and # carries the contract keys. Behavioural coverage of the underlying flows lives # in the existing per-command test classes above. class TestNoteJsonFlagsRegistered: """Verify --json appears in --help for every note subcommand.""" @pytest.mark.parametrize("subcommand", ["get", "save", "create", "delete", "rename"]) def test_json_flag_in_help(self, runner, subcommand): result = runner.invoke(cli, ["note", subcommand, "--help"]) assert result.exit_code == 0, result.output assert "--json" in result.output class TestNoteCreateJson: """JSON shape for ``note create``.""" def test_create_success(self, runner, mock_auth): mock_client = create_mock_client() mock_client.notes.create = AsyncMock(return_value=make_note("note_new", "My Note", "Hello")) 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, [ "note", "create", "Hello", "--title", "My Note", "-n", "nb_123", "--json", ], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["id"] == "note_new" assert data["created"] is True assert data["title"] == "My Note" assert data["notebook_id"] == "nb_123" def test_create_json_emits_real_id_on_typed_note(self, runner, mock_auth): """``note create --json`` emits the REAL note id + ``"created": true``. Regression for the dead-decoder bug: ``notes.create`` was typed to return a ``Note``, but a leftover raw-list decoder in the ``_app`` layer still tried to read ``result[0]`` and yielded ``None`` — so the CLI emitted ``{"id": null, "created": false, "error": "Creation may have failed"}`` on EVERY successful create. The bug was masked in this suite by stale raw-list mocks; this test pins the typed-facade path. """ mock_client = create_mock_client() mock_client.notes.create = AsyncMock( return_value=make_note("note_real_id", "Pinned", "Body") ) 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, ["note", "create", "Body", "--title", "Pinned", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["id"] == "note_real_id" assert data["created"] is True assert "error" not in data class TestNoteGetJson: """JSON shape for ``note get`` (mirrors Note dataclass).""" def test_get_success(self, runner, mock_auth): mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "T", "Body")]) mock_client.notes.get_or_none = AsyncMock(return_value=make_note("note_123", "T", "Body")) 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, ["note", "get", "note_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) # Note dataclass mirror + injected ``found`` discriminator so # callers can distinguish success from the failure shape with a # single ``data["found"]`` check. assert data["found"] is True assert data["id"] == "note_123" assert data["notebook_id"] == "nb_123" assert data["title"] == "T" assert data["content"] == "Body" assert "created_at" in data def test_get_resolves_but_returns_none(self, runner, mock_auth): """When resolve succeeds but the GET returns None, exit 1 with typed JSON. The contract was flipped from the previous exit-0 ``{found: false}`` placeholder to the standard typed JSON error envelope (``{error, code, message}``) + exit 1. See ``docs/cli-exit-codes.md`` and the BREAKING entry in ``CHANGELOG.md`` (Unreleased → Changed). """ mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "T", "B")]) mock_client.notes.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, ["note", "get", "note_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 "Note not found" in data["message"] assert data["id"] == "note_123" assert data["notebook_id"] == "nb_123" class TestNoteSaveJson: """JSON shape for ``note save``.""" def test_save_content(self, runner, mock_auth): mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "T", "Old")]) mock_client.notes.update = 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, [ "note", "save", "note_123", "--content", "New body", "-n", "nb_123", "--json", ], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["id"] == "note_123" assert data["saved"] is True assert data["content"] == "New body" def test_save_no_changes(self, runner, mock_auth): """No --title/--content with --json still emits parseable JSON.""" mock_client = create_mock_client() 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, ["note", "save", "note_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["saved"] is False assert "error" in data # ``notebook_id`` is part of the contract on every JSON response # in this module — assert presence so the no-op shape stays in # sync with the rest. Value is the raw CLI argument here, so it # is the literal string the caller passed. assert data["notebook_id"] == "nb_123" """JSON shape for ``note rename``.""" def test_rename_success(self, runner, mock_auth): mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "Old", "Body")]) mock_client.notes.get_or_none = AsyncMock(return_value=make_note("note_123", "Old", "Body")) mock_client.notes.update = 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, [ "note", "rename", "note_123", "New Title", "-n", "nb_123", "--json", ], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["id"] == "note_123" assert data["title"] == "New Title" assert data["renamed"] is True def test_rename_target_missing_after_resolve_json_exits_1(self, runner, mock_auth): """``rename`` race: resolve succeeds, then GET returns ``None`` (note deleted between the two calls) → typed JSON ``NOT_FOUND`` envelope + exit ``1``. Mirrors ``note get``'s Path B contract (see ``test_get_resolves_but_returns_none``). The prior shape was a ``{renamed: false, error: "Note not found"}`` payload on exit ``0``, which silently passed in ``set -e`` scripts that branch on the exit code. See ``docs/cli-exit-codes.md`` and the BREAKING entry in ``CHANGELOG.md`` (Unreleased → Changed). """ mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "Old", "Body")]) mock_client.notes.get_or_none = AsyncMock(return_value=None) mock_client.notes.update = 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, [ "note", "rename", "note_123", "New", "-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 "Note not found" in data["message"] assert data["id"] == "note_123" assert data["notebook_id"] == "nb_123" # The race-loser path must NOT issue an update RPC. mock_client.notes.update.assert_not_called() def test_rename_target_missing_after_resolve_text_exits_1(self, runner, mock_auth): """Same race in text mode: ``Note not found`` on stderr + exit 1.""" mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "Old", "Body")]) mock_client.notes.get_or_none = AsyncMock(return_value=None) mock_client.notes.update = 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, ["note", "rename", "note_123", "New", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code == 1, result.output assert "Note not found" in result.output mock_client.notes.update.assert_not_called() class TestNoteDeleteJson: """JSON shape for ``note delete``.""" def test_delete_success(self, runner, mock_auth): mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "T", "Body")]) 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, [ "note", "delete", "note_123", "-n", "nb_123", "-y", "--json", ], obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["id"] == "note_123" assert data["deleted"] is True def test_delete_json_without_yes_emits_typed_error(self, runner, mock_auth): """``--json`` mode without ``--yes`` must NOT prompt — it would corrupt stdout and break ``subprocess.check_output(...) -> json.loads(...)`` callers. Instead, surface a typed JSON error and exit non-zero so the ``set -e`` / ``check_call`` family of script idioms catches the misconfiguration immediately (audit P1.T5). Migration from the prior shape: the response previously emitted ``{deleted: false, error: "Pass --yes ..."}`` on exit ``0``, which passed silently in scripts that branched on the exit code. The new contract uses the standard typed envelope (``{error, code: "VALIDATION_ERROR", message, ...}``) + exit ``1``. See ``docs/cli-exit-codes.md`` and the BREAKING entry in ``CHANGELOG.md`` (Unreleased → Changed). """ mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "T", "Body")]) # ``delete`` must NOT be invoked — assert that below. 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") # No --yes, no input piped: the command must NOT block on # stdin. If it did, ``runner.invoke`` would hang. result = runner.invoke( cli, ["note", "delete", "note_123", "-n", "nb_123", "--json"], obj=inject_client(mock_client), ) assert result.exit_code == 1, result.output # Stdout must be ONLY parseable JSON — no prompt residue. data = json.loads(result.output) assert data["error"] is True assert data["code"] == "VALIDATION_ERROR" assert "--yes" in data["message"] assert data["id"] == "note_123" assert data["notebook_id"] == "nb_123" mock_client.notes.delete.assert_not_called() def test_delete_non_json_cancelled(self, runner, mock_auth): """Non-JSON mode preserves the interactive prompt; declining is a no-op.""" mock_client = create_mock_client() mock_client.notes.list = AsyncMock(return_value=[make_note("note_123", "T", "Body")]) 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, ["note", "delete", "note_123", "-n", "nb_123"], input="n\n", obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output # Prompt was printed; deletion was NOT performed. assert "Delete note" in result.output mock_client.notes.delete.assert_not_called() # ============================================================================= # Stdin (`-`) convention for ``note create`` # ============================================================================= # # Unix tradition: ``-`` as a value means "read from stdin". A ``--content`` # flag on ``note create`` makes ``cat notes.md | notebooklm # note create --content -`` is the canonical pipeline. The positional # ``CONTENT`` argument also accepts ``-`` for the same reason. Both must be # mutually exclusive (passing both is a UsageError). class TestNoteCreateStdinDash: """``note create --content -`` and ``note create -`` accept piped stdin.""" def test_note_create_content_flag_dash_reads_stdin(self, runner, mock_auth): mock_client = create_mock_client() mock_client.notes.create = AsyncMock( return_value=make_note("note_new", "New Note", "from stdin") ) 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, ["note", "create", "--content", "-", "-n", "nb_123"], input="from stdin\n", obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output # client.notes.create(notebook_id, title, content) call = mock_client.notes.create.call_args assert call.args[2] == "from stdin" def test_note_create_positional_dash_reads_stdin(self, runner, mock_auth): mock_client = create_mock_client() mock_client.notes.create = AsyncMock( return_value=make_note("note_new", "New Note", "piped body") ) 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, ["note", "create", "-", "-n", "nb_123"], input="piped body\n", obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output call = mock_client.notes.create.call_args assert call.args[2] == "piped body" def test_note_create_content_flag_literal_value_unchanged(self, runner, mock_auth): """Regression: ``--content "literal"`` is not interpreted as stdin.""" mock_client = create_mock_client() mock_client.notes.create = AsyncMock( return_value=make_note("note_new", "New Note", "literal") ) 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, ["note", "create", "--content", "literal", "-n", "nb_123"], input="ignored\n", obj=inject_client(mock_client), ) assert result.exit_code == 0, result.output call = mock_client.notes.create.call_args assert call.args[2] == "literal" def test_note_create_positional_and_content_flag_conflict(self, runner, mock_auth): """Passing both positional CONTENT and --content is a UsageError.""" mock_client = create_mock_client() # Never awaited — the UsageError fires before any client call; a typed # Note keeps the mock contract-faithful anyway. mock_client.notes.create = AsyncMock(return_value=make_note("note_new", "New Note", "x")) 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, ["note", "create", "positional body", "--content", "flag body", "-n", "nb_123"], obj=inject_client(mock_client), ) assert result.exit_code != 0 assert "Cannot use both" in result.output