09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
1015 lines
40 KiB
Python
1015 lines
40 KiB
Python
"""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": ..., "<verb>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
|