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
3386 lines
134 KiB
Python
3386 lines
134 KiB
Python
"""Tests for source CLI commands."""
|
|
|
|
import contextlib
|
|
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 (
|
|
Source,
|
|
SourceFulltext,
|
|
SourceNotFoundError,
|
|
SourceProcessingError,
|
|
SourceStatus,
|
|
SourceTimeoutError,
|
|
)
|
|
|
|
from .conftest import (
|
|
create_mock_client,
|
|
inject_client,
|
|
research_start,
|
|
research_task,
|
|
source_guide,
|
|
)
|
|
|
|
source_module = importlib.import_module("notebooklm.cli.source_cmd")
|
|
research_import_module = importlib.import_module("notebooklm.cli.research_import")
|
|
|
|
|
|
@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
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE LIST TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceList:
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_list(self, runner, mock_auth, output_mode):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[
|
|
Source(
|
|
id="src_1",
|
|
title="Source One",
|
|
url="https://example.com",
|
|
_type_code=5,
|
|
),
|
|
]
|
|
)
|
|
mock_client.notebooks.get = AsyncMock(return_value=MagicMock(title="Test Notebook"))
|
|
|
|
args = ["source", "list", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
if output_mode == "text":
|
|
assert "Sources in nb_123" in result.output
|
|
assert "src_1" in result.output
|
|
assert "Source One" in result.output
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert list(data) == ["notebook_id", "notebook_title", "sources", "count"]
|
|
assert data["notebook_id"] == "nb_123"
|
|
assert data["notebook_title"] == "Test Notebook"
|
|
assert "sources" in data
|
|
assert data["count"] == 1
|
|
assert list(data["sources"][0]) == [
|
|
"index",
|
|
"id",
|
|
"title",
|
|
"type",
|
|
"url",
|
|
"status",
|
|
"status_id",
|
|
"created_at",
|
|
]
|
|
assert data["sources"][0]["id"] == "src_1"
|
|
assert data["sources"][0]["type"] == "web_page"
|
|
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_list_limit_caps_rows(self, runner, mock_auth, output_mode):
|
|
"""`source list --limit N` returns at most N data rows in both formats."""
|
|
many = [Source(id=f"src_{i:02d}", title=f"Source {i:02d}") for i in range(20)]
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=many)
|
|
mock_client.notebooks.get = AsyncMock(return_value=MagicMock(title="Test"))
|
|
|
|
limit = "4" if output_mode == "text" else "2"
|
|
args = ["source", "list", "-n", "nb_123", "--limit", limit]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0, result.output
|
|
if output_mode == "text":
|
|
for i in range(4):
|
|
assert f"src_{i:02d}" in result.output
|
|
for i in range(4, 20):
|
|
assert f"src_{i:02d}" not in result.output
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["count"] == 2
|
|
assert len(data["sources"]) == 2
|
|
assert [s["id"] for s in data["sources"]] == ["src_00", "src_01"]
|
|
|
|
def test_source_list_no_truncate_disables_ellipsis(self, runner, mock_auth):
|
|
"""`source 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()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_long", title=long_title)])
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "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_source_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.sources.list = AsyncMock(return_value=[Source(id="src_long", title=long_title)])
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli, ["source", "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
|
|
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_list_label_filter_restricts_to_group(self, runner, mock_auth, output_mode):
|
|
"""`source list --label <id>` returns only the label's sources.
|
|
|
|
The filter is injected into the fetch closure so the JSON ``count``/rows
|
|
match the filtered set (no post-filter desync). Resolution reuses
|
|
``client.labels.sources()`` for the membership set.
|
|
"""
|
|
from notebooklm.types import Label
|
|
|
|
all_sources = [
|
|
Source(id="src_1", title="In Group"),
|
|
Source(id="src_2", title="Not In Group"),
|
|
]
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=all_sources)
|
|
mock_client.notebooks.get = AsyncMock(return_value=MagicMock(title="Test"))
|
|
mock_client.labels = MagicMock()
|
|
mock_client.labels.list = AsyncMock(
|
|
return_value=[Label(id="lblaaa111", name="Papers", source_ids=["src_1"])]
|
|
)
|
|
mock_client.labels.sources = AsyncMock(return_value=[all_sources[0]])
|
|
|
|
args = ["source", "list", "-n", "nb_123", "--label", "lblaaa111"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0, result.output
|
|
if output_mode == "json":
|
|
data = json.loads(result.output)
|
|
# Envelope key stays "sources"; count + rows match filtered set.
|
|
assert "sources" in data
|
|
assert data["count"] == 1
|
|
assert [s["id"] for s in data["sources"]] == ["src_1"]
|
|
else:
|
|
assert "src_1" in result.output
|
|
assert "src_2" not in result.output
|
|
|
|
def test_source_list_label_filter_by_name(self, runner, mock_auth):
|
|
"""`source list --label <name>` resolves the label name before filtering."""
|
|
from notebooklm.types import Label
|
|
|
|
all_sources = [Source(id="src_1", title="In Group"), Source(id="src_2", title="Out")]
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=all_sources)
|
|
mock_client.notebooks.get = AsyncMock(return_value=MagicMock(title="Test"))
|
|
mock_client.labels = MagicMock()
|
|
mock_client.labels.list = AsyncMock(
|
|
return_value=[Label(id="lblaaa111", name="Papers", source_ids=["src_1"])]
|
|
)
|
|
mock_client.labels.sources = AsyncMock(return_value=[all_sources[0]])
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "list", "-n", "nb_123", "--label", "Papers", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
mock_client.labels.sources.assert_awaited_once_with("nb_123", "lblaaa111")
|
|
data = json.loads(result.output)
|
|
assert [s["id"] for s in data["sources"]] == ["src_1"]
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE ADD TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceAdd:
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_add_url(self, runner, mock_auth, output_mode):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=Source(
|
|
id="src_new",
|
|
title="Example",
|
|
url="https://example.com",
|
|
_type_code=5,
|
|
)
|
|
)
|
|
|
|
args = ["source", "add", "https://example.com", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
if output_mode == "json":
|
|
data = json.loads(result.output)
|
|
assert data["source"]["id"] == "src_new"
|
|
assert data["source"]["type"] == "web_page"
|
|
|
|
def test_source_add_youtube_url(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=Source(
|
|
id="src_yt",
|
|
title="YouTube Video",
|
|
url="https://youtube.com/watch?v=abc",
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "https://youtube.com/watch?v=abc123", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_url.assert_called()
|
|
|
|
def test_source_add_text(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="My Text Source")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "Some text content", "--type", "text", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_source_add_text_with_title(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Custom Title")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add",
|
|
"My notes",
|
|
"--type",
|
|
"text",
|
|
"--title",
|
|
"Custom Title",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_source_add_file(self, runner, mock_auth, tmp_path):
|
|
# Create a temp file
|
|
test_file = tmp_path / "test.pdf"
|
|
test_file.write_bytes(b"fake pdf content")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_file = AsyncMock(
|
|
return_value=Source(id="src_file", title="test.pdf")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", str(test_file), "--type", "file", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_source_add_file_with_mime_type_forwards_to_add_file(self, runner, mock_auth, tmp_path):
|
|
"""``--mime-type`` on the file-source path controls upload content type."""
|
|
test_file = tmp_path / "test.pdf"
|
|
test_file.write_bytes(b"fake pdf content")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_file = AsyncMock(
|
|
return_value=Source(id="src_file", title="test.pdf")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add",
|
|
str(test_file),
|
|
"--type",
|
|
"file",
|
|
"--mime-type",
|
|
"application/pdf",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "unused for file sources" not in result.output
|
|
call_kwargs = mock_client.sources.add_file.call_args.kwargs
|
|
call_args = mock_client.sources.add_file.call_args.args
|
|
assert "mime_type" not in call_kwargs
|
|
assert call_args == ("nb_123", str(test_file.resolve()), "application/pdf")
|
|
|
|
def test_source_add_file_mime_type_suppressed_by_env(
|
|
self, runner, mock_auth, tmp_path, monkeypatch
|
|
):
|
|
"""Legacy deprecation-suppression env does not disable live MIME forwarding."""
|
|
test_file = tmp_path / "test.pdf"
|
|
test_file.write_bytes(b"fake pdf content")
|
|
monkeypatch.setenv("NOTEBOOKLM_QUIET_DEPRECATIONS", "1")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_file = AsyncMock(
|
|
return_value=Source(id="src_file", title="test.pdf")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add",
|
|
str(test_file),
|
|
"--type",
|
|
"file",
|
|
"--mime-type",
|
|
"application/pdf",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "unused for file sources" not in result.output
|
|
call_args = mock_client.sources.add_file.call_args.args
|
|
assert call_args == ("nb_123", str(test_file.resolve()), "application/pdf")
|
|
|
|
def test_source_add_timeout_flag_threaded_to_client(self, runner, mock_auth):
|
|
client_calls: list = []
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=Source(id="src_t", title="X", url="https://example.com")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add",
|
|
"https://example.com",
|
|
"-n",
|
|
"nb_123",
|
|
"--timeout",
|
|
"120",
|
|
],
|
|
obj=inject_client(mock_client, recorder=client_calls),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert client_calls[-1][1]["timeout"] == 120.0
|
|
|
|
def test_source_add_default_does_not_override_client_timeout(self, runner, mock_auth):
|
|
client_calls: list = []
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=Source(id="src_d", title="Y", url="https://example.com")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "https://example.com", "-n", "nb_123"],
|
|
obj=inject_client(mock_client, recorder=client_calls),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "timeout" not in client_calls[-1][1]
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE GET TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceGet:
|
|
def test_source_get(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
# Mock sources.list for resolve_source_id
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_or_none = AsyncMock(
|
|
return_value=Source(
|
|
id="src_123",
|
|
title="Test Source",
|
|
url="https://example.com",
|
|
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, ["source", "get", "src_123", "-n", "nb_123"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Test Source" in result.output
|
|
assert "src_123" in result.output
|
|
|
|
def test_source_get_not_found(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
# Mock sources.list to return empty (no match for resolve_source_id)
|
|
mock_client.sources.list = AsyncMock(return_value=[])
|
|
mock_client.sources.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,
|
|
["source", "get", "nonexistent", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
# Now exits with error from resolve_source_id (no match)
|
|
assert result.exit_code == 1
|
|
assert "No source found" in result.output
|
|
|
|
# -------------------------------------------------------------------------
|
|
# get-on-not-found now exits 1 (was 0). The tests below
|
|
# cover BOTH code paths for the not-found branch:
|
|
#
|
|
# * Path A: input ID matches ``FULL_ID_PATTERN`` (canonical 36-char UUID
|
|
# in 8-4-4-4-12 layout), so ``_resolve_partial_id`` skips the list()
|
|
# round-trip and returns the input as-is. The backend
|
|
# ``client.sources.get`` then returns ``None``.
|
|
# * Path B: input ID is NOT UUID-shaped, ``_resolve_partial_id`` resolves
|
|
# it via a list() match (so the partial-resolve "not found"
|
|
# ClickException branch is intentionally NOT exercised here — that
|
|
# path is unchanged and tested by ``test_source_get_not_found``
|
|
# above), but the backend get returns ``None`` (e.g. concurrent
|
|
# delete from another session).
|
|
#
|
|
# Each path is exercised in text and ``--json`` mode.
|
|
# -------------------------------------------------------------------------
|
|
|
|
def test_source_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
|
|
# sources.list is bypassed and the backend ``get`` is hit directly.
|
|
long_id = "abc12345-6789-4abc-def0-1234567890ab"
|
|
mock_client = create_mock_client()
|
|
# list() must NOT be called on this path; assert below.
|
|
mock_client.sources.list = AsyncMock(return_value=[])
|
|
mock_client.sources.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, ["source", "get", long_id, "-n", "nb_123"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "Source not found" in result.output
|
|
mock_client.sources.list.assert_not_called()
|
|
|
|
def test_source_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.sources.list = AsyncMock(return_value=[])
|
|
mock_client.sources.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,
|
|
["source", "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 "Source not found" in data["message"]
|
|
assert data["source_id"] == long_id
|
|
mock_client.sources.list.assert_not_called()
|
|
|
|
def test_source_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()
|
|
# Resolution succeeds (list contains a matching item) but the
|
|
# subsequent get() returns None (race: source deleted between
|
|
# list and get).
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_resolved", title="Doomed")]
|
|
)
|
|
mock_client.sources.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,
|
|
["source", "get", "src_resolved", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "Source not found" in result.output
|
|
|
|
def test_source_get_not_found_pathB_resolved_then_none_json_exits_1(self, runner, mock_auth):
|
|
"""Path B under ``--json``: typed JSON error doc + exit 1."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_resolved", title="Doomed")]
|
|
)
|
|
mock_client.sources.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,
|
|
["source", "get", "src_resolved", "-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 "Source not found" in data["message"]
|
|
assert data["source_id"] == "src_resolved"
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE DELETE TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceDelete:
|
|
def test_source_delete(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
# Mock sources.list for source delete resolution
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "src_123", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Deleted source" in result.output
|
|
mock_client.sources.delete.assert_called_once_with("nb_123", "src_123")
|
|
|
|
def test_source_delete_cancelled(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "src_123", "-n", "nb_123"],
|
|
input="n\n",
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Delete source src_123?" in result.output
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_delete_failure(self, runner, mock_auth):
|
|
"""A real delete failure now raises (v0.7.0): delete() returns None and
|
|
propagates RPC/transport errors instead of signalling failure via a
|
|
falsy return (issue #1211)."""
|
|
from notebooklm.exceptions import RPCError
|
|
|
|
mock_client = create_mock_client()
|
|
# Mock sources.list for source delete resolution
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(side_effect=RPCError("delete blew up"))
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "src_123", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
|
|
def test_source_delete_full_uuid_skips_source_list(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock()
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
source_id = "03abe51c-d8df-43ba-ae2d-0efe02c71c4a"
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", source_id, "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.list.assert_not_called()
|
|
mock_client.sources.delete.assert_called_once_with("nb_123", source_id)
|
|
|
|
def test_source_delete_long_hex_string_does_not_skip_source_list(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[])
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
source_id = "03abe51cd8df43baae2d0efe02c71c4a"
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", source_id, "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
mock_client.sources.list.assert_called_once_with("nb_123")
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_delete_title_suggests_delete_by_title(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[
|
|
Source(
|
|
id="03abe51c-d8df-43ba-ae2d-0efe02c71c4a",
|
|
title="Emails_Verzonden_2026_02",
|
|
)
|
|
]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "Emails_Verzonden_2026_02", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "delete-by-title" in result.output
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_delete_unknown_long_string_fails_locally(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "Emails_Verzonden_2025-Q1.txt", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "No source found starting with" in result.output
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_delete_ambiguous_partial_id(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[
|
|
Source(id="src_123", title="First Source"),
|
|
Source(id="src_456", title="Second Source"),
|
|
]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "src", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "Ambiguous ID 'src' matches 2 sources" in result.output
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
|
|
class TestSourceDeleteByTitle:
|
|
def test_source_delete_by_title(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete-by-title", "Test Source", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Deleted source" in result.output
|
|
mock_client.sources.delete.assert_called_once_with("nb_123", "src_123")
|
|
|
|
def test_source_delete_by_title_duplicate_titles(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[
|
|
Source(id="src_123", title="Duplicate"),
|
|
Source(id="src_456", title="Duplicate"),
|
|
]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete-by-title", "Duplicate", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "Delete by ID instead" in result.output
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_delete_by_title_not_found(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[])
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete-by-title", "Missing", "-n", "nb_123", "-y"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "No source found with title" in result.output
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_delete_by_title_confirmation_shows_title_and_id(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete-by-title", "Test Source", "-n", "nb_123"],
|
|
input="y\n",
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Delete source 'Test Source' (src_123)?" in result.output
|
|
mock_client.sources.delete.assert_called_once_with("nb_123", "src_123")
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE RENAME TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceRename:
|
|
def test_source_rename(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
# Mock sources.list for resolve_source_id
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="Old Title")])
|
|
mock_client.sources.rename = AsyncMock(return_value=Source(id="src_123", title="New Title"))
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "rename", "src_123", "New Title", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Renamed source" in result.output
|
|
assert "New Title" in result.output
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE REFRESH TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceRefresh:
|
|
def test_source_refresh(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
# Mock sources.list for resolve_source_id
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Original Source")]
|
|
)
|
|
mock_client.sources.refresh = AsyncMock(
|
|
return_value=Source(id="src_123", title="Refreshed Source")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "refresh", "src_123", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Source refreshed" in result.output
|
|
|
|
def test_source_refresh_none_prints_refreshed(self, runner, mock_auth):
|
|
# v0.8.0 (#1290): refresh() returns None on success; the CLI must still
|
|
# render "Source refreshed", not "no result".
|
|
mock_client = create_mock_client()
|
|
# Mock sources.list for resolve_source_id
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Original Source")]
|
|
)
|
|
mock_client.sources.refresh = AsyncMock(return_value=None)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "refresh", "src_123", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Source refreshed" in result.output
|
|
assert "no result" not in result.output
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE ADD-DRIVE TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceAddDrive:
|
|
def test_source_add_drive_google_doc(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_drive = AsyncMock(
|
|
return_value=Source(
|
|
id="src_drive",
|
|
title="My Google Doc",
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add-drive", "drive_file_id", "My Google Doc", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Added Drive source" in result.output
|
|
|
|
def test_source_add_drive_pdf(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_drive = AsyncMock(
|
|
return_value=Source(
|
|
id="src_drive",
|
|
title="PDF from Drive",
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-drive",
|
|
"file_id",
|
|
"PDF Title",
|
|
"--mime-type",
|
|
"pdf",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_source_add_drive_mime_type_no_deprecation_warning(self, runner, mock_auth):
|
|
"""Regression guard: Drive ``--mime-type`` MUST stay deprecation-free.
|
|
|
|
The Drive ``--mime-type`` flag is live — it selects the ``DriveMimeType``
|
|
value the API consumes (``google-doc``/``google-slides``/
|
|
``google-sheets``/``pdf``). The file-source deprecation message must
|
|
NEVER appear on this command, regardless of which Drive MIME the user
|
|
picks.
|
|
"""
|
|
for choice in ("google-doc", "google-slides", "google-sheets", "pdf"):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_drive = AsyncMock(
|
|
return_value=Source(id="src_drive", title="My Drive Source")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-drive",
|
|
"file_id",
|
|
"Drive Title",
|
|
"--mime-type",
|
|
choice,
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, f"Drive --mime-type {choice} failed: {result.output}"
|
|
assert "unused for file sources" not in result.output, (
|
|
f"Drive --mime-type={choice} unexpectedly triggered the "
|
|
f"file-source deprecation notice"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE ADD-RESEARCH TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceAddResearch:
|
|
def test_add_research_with_import_all_uses_retry_helper(self, runner, mock_auth):
|
|
with (
|
|
patch.object(
|
|
research_import_module, "import_with_retry", new_callable=AsyncMock
|
|
) as mock_import,
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.research.start = AsyncMock(
|
|
return_value=research_start({"task_id": "task_123"})
|
|
)
|
|
mock_client.research.poll = AsyncMock(
|
|
return_value=research_task(
|
|
{
|
|
"status": "completed",
|
|
"task_id": "task_123",
|
|
"sources": [{"title": "Source 1", "url": "http://example.com"}],
|
|
"report": "# Report",
|
|
}
|
|
)
|
|
)
|
|
mock_import.return_value = [{"id": "src_1", "title": "Source 1"}]
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-research",
|
|
"AI papers",
|
|
"--mode",
|
|
"deep",
|
|
"--import-all",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Imported 1 sources" in result.output
|
|
mock_import.assert_awaited_once_with(
|
|
mock_client,
|
|
"nb_123",
|
|
"task_123",
|
|
[{"url": "http://example.com", "title": "Source 1", "result_type": 1}],
|
|
max_elapsed=1800,
|
|
)
|
|
|
|
def test_add_research_with_import_all_cited_only(self, runner, mock_auth):
|
|
with (
|
|
patch.object(
|
|
research_import_module, "import_with_retry", new_callable=AsyncMock
|
|
) as mock_import,
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.research.start = AsyncMock(
|
|
return_value=research_start({"task_id": "task_123"})
|
|
)
|
|
mock_client.research.poll = AsyncMock(
|
|
return_value=research_task(
|
|
{
|
|
"status": "completed",
|
|
"task_id": "task_123",
|
|
"sources": [
|
|
{"title": "Cited", "url": "https://example.com/cited"},
|
|
{"title": "Uncited", "url": "https://example.com/uncited"},
|
|
],
|
|
"report": "Report cites https://example.com/cited",
|
|
}
|
|
)
|
|
)
|
|
mock_import.return_value = [{"id": "src_1", "title": "Cited"}]
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-research",
|
|
"AI papers",
|
|
"--import-all",
|
|
"--cited-only",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Imported 1 sources" in result.output
|
|
mock_import.assert_awaited_once_with(
|
|
mock_client,
|
|
"nb_123",
|
|
"task_123",
|
|
[{"url": "https://example.com/cited", "title": "Cited", "result_type": 1}],
|
|
max_elapsed=1800,
|
|
)
|
|
|
|
def test_add_research_cited_only_requires_import_all(
|
|
self, runner, mock_auth, mock_fetch_tokens
|
|
):
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add-research", "AI papers", "--cited-only", "-n", "nb_123"],
|
|
)
|
|
|
|
# ``click.UsageError`` exits 2 — Click's standard convention.
|
|
assert result.exit_code == 2
|
|
assert "--cited-only requires --import-all" in result.output
|
|
|
|
def test_add_research_timeout_flag_threaded_to_import_with_retry(self, runner, mock_auth):
|
|
with (
|
|
patch.object(
|
|
research_import_module, "import_with_retry", new_callable=AsyncMock
|
|
) as mock_import,
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.research.start = AsyncMock(
|
|
return_value=research_start({"task_id": "task_t1"})
|
|
)
|
|
mock_client.research.poll = AsyncMock(
|
|
return_value=research_task(
|
|
{
|
|
"status": "completed",
|
|
"task_id": "task_t1",
|
|
"sources": [{"title": "S", "url": "http://example.com"}],
|
|
"report": "",
|
|
}
|
|
)
|
|
)
|
|
mock_import.return_value = [{"id": "src_t1", "title": "S"}]
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-research",
|
|
"topic",
|
|
"--import-all",
|
|
"--timeout",
|
|
"600",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_import.await_args.kwargs["max_elapsed"] == 600
|
|
|
|
def test_add_research_poll_budget_respects_timeout(self, runner, mock_auth):
|
|
"""Regression for #315: deep research that completes after the legacy
|
|
hardcoded 300 s poll cap must still trigger ``IMPORT_RESEARCH`` when
|
|
the caller bumps ``--timeout``.
|
|
|
|
Before the fix the poll loop was ``for _ in range(60)`` with a fixed
|
|
5 s interval, so deep-research tasks running longer than 5 minutes
|
|
timed out and the import branch was skipped entirely — leaving the
|
|
web UI's "Add sources?" modal hanging open server-side."""
|
|
in_progress = research_task(
|
|
{
|
|
"status": "in_progress",
|
|
"task_id": "task_long",
|
|
"sources": [],
|
|
}
|
|
)
|
|
completed = research_task(
|
|
{
|
|
"status": "completed",
|
|
"task_id": "task_long",
|
|
"sources": [{"title": "S", "url": "http://example.com"}],
|
|
"report": "",
|
|
}
|
|
)
|
|
# 70 in-progress polls before completion — past the legacy 60-cap,
|
|
# well within a 600 s / 5 s budget (=120 polls).
|
|
poll_responses = [in_progress] * 70 + [completed]
|
|
|
|
with (
|
|
patch.object(
|
|
research_import_module, "import_with_retry", new_callable=AsyncMock
|
|
) as mock_import,
|
|
patch("asyncio.sleep", new_callable=AsyncMock),
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.research.start = AsyncMock(
|
|
return_value=research_start({"task_id": "task_long"})
|
|
)
|
|
mock_client.research.poll = AsyncMock(side_effect=poll_responses)
|
|
mock_import.return_value = [{"id": "src_long", "title": "S"}]
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-research",
|
|
"topic",
|
|
"--mode",
|
|
"deep",
|
|
"--import-all",
|
|
"--timeout",
|
|
"600",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Imported 1 sources" in result.output
|
|
mock_import.assert_awaited_once()
|
|
assert mock_client.research.poll.await_count == 71
|
|
|
|
def test_add_research_tolerates_initial_no_research_after_start(self, runner, mock_auth):
|
|
"""NotebookLM can briefly return no_research immediately after START_RESEARCH."""
|
|
poll_responses = [
|
|
research_task({"status": "no_research", "tasks": []}),
|
|
research_task({"status": "in_progress", "task_id": "task_lag", "sources": []}),
|
|
research_task(
|
|
{"status": "completed", "task_id": "task_lag", "sources": [], "report": ""}
|
|
),
|
|
]
|
|
|
|
with (
|
|
patch.object(source_module.asyncio, "sleep", AsyncMock()),
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.research.start = AsyncMock(
|
|
return_value=research_start({"task_id": "task_lag"})
|
|
)
|
|
mock_client.research.poll = AsyncMock(side_effect=poll_responses)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add-research", "topic", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert mock_client.research.poll.await_count == 3
|
|
|
|
def test_add_research_deep_polls_with_report_id(self, runner, mock_auth):
|
|
"""Deep research's poll/import discriminator is START_DEEP_RESEARCH report_id."""
|
|
with (
|
|
patch.object(
|
|
research_import_module, "import_with_retry", new_callable=AsyncMock
|
|
) as mock_import,
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.research.start = AsyncMock(
|
|
return_value=research_start({"task_id": "start_task", "report_id": "report_task"})
|
|
)
|
|
mock_client.research.poll = AsyncMock(
|
|
return_value=research_task(
|
|
{
|
|
"status": "completed",
|
|
"task_id": "report_task",
|
|
"sources": [{"title": "S", "url": "http://example.com"}],
|
|
"report": "",
|
|
}
|
|
)
|
|
)
|
|
mock_import.return_value = [{"id": "src_report", "title": "S"}]
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-research",
|
|
"topic",
|
|
"--mode",
|
|
"deep",
|
|
"--import-all",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
mock_client.research.poll.assert_awaited_once_with("nb_123", task_id="report_task")
|
|
mock_import.assert_awaited_once()
|
|
assert mock_import.await_args.args[2] == "report_task"
|
|
|
|
|
|
# =============================================================================
|
|
# COMMAND EXISTENCE TESTS
|
|
# =============================================================================
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE GUIDE TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceGuide:
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_guide_with_summary_and_keywords(self, runner, mock_auth, output_mode):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_guide = AsyncMock(
|
|
return_value=source_guide(
|
|
{
|
|
"summary": "This is a **test** summary about AI.",
|
|
"keywords": ["AI", "machine learning", "data science"],
|
|
}
|
|
)
|
|
)
|
|
|
|
args = ["source", "guide", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
if output_mode == "text":
|
|
assert "Summary" in result.output
|
|
assert "test" in result.output
|
|
assert "Keywords" in result.output
|
|
assert "AI" in result.output
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["source_id"] == "src_123"
|
|
assert data["summary"] == "This is a **test** summary about AI."
|
|
assert data["keywords"] == ["AI", "machine learning", "data science"]
|
|
|
|
def test_source_guide_no_guide_available(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_guide = AsyncMock(
|
|
return_value=source_guide({"summary": "", "keywords": []})
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli, ["source", "guide", "src_123", "-n", "nb_123"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "No guide available" in result.output
|
|
|
|
def test_source_guide_summary_only(self, runner, mock_auth):
|
|
"""Test that summary is displayed even when keywords are empty."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_guide = AsyncMock(
|
|
return_value=source_guide({"summary": "Summary without keywords", "keywords": []})
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli, ["source", "guide", "src_123", "-n", "nb_123"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Summary" in result.output
|
|
assert "Summary without keywords" in result.output
|
|
assert "No guide available" not in result.output
|
|
|
|
def test_source_guide_keywords_only(self, runner, mock_auth):
|
|
"""Test that keywords are displayed even when summary is empty."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_guide = AsyncMock(
|
|
return_value=source_guide({"summary": "", "keywords": ["AI", "ML", "Data"]})
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli, ["source", "guide", "src_123", "-n", "nb_123"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Keywords" in result.output
|
|
assert "AI" in result.output
|
|
assert "No guide available" not in result.output
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE STALE TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceStale:
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_stale_is_stale(self, runner, mock_auth, output_mode):
|
|
"""Default exit code is 0 (success) when the check completes — stale branch.
|
|
|
|
The freshness result is reported on stdout; callers branch on the
|
|
text (or, with --json, on the ``stale`` field). See
|
|
docs/cli-exit-codes.md.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.check_freshness = AsyncMock(return_value=False) # Not fresh = stale
|
|
|
|
args = ["source", "stale", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0 # success — check completed
|
|
if output_mode == "text":
|
|
assert "stale" in result.output.lower()
|
|
assert "refresh" in result.output.lower()
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["stale"] is True
|
|
assert data["fresh"] is False
|
|
assert data["source_id"] == "src_123"
|
|
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_stale_is_fresh(self, runner, mock_auth, output_mode):
|
|
"""Default exit code is 0 (success) when the check completes — fresh branch."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.check_freshness = AsyncMock(return_value=True) # Fresh
|
|
|
|
args = ["source", "stale", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0 # success — check completed
|
|
if output_mode == "text":
|
|
assert "fresh" in result.output.lower()
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["stale"] is False
|
|
assert data["fresh"] is True
|
|
assert data["source_id"] == "src_123"
|
|
|
|
|
|
# =============================================================================
|
|
# COMMAND EXISTENCE TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceCommandsExist:
|
|
def test_source_group_exists(self, runner):
|
|
result = runner.invoke(cli, ["source", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "list" in result.output
|
|
assert "add" in result.output
|
|
assert "delete" in result.output
|
|
assert "guide" in result.output
|
|
assert "stale" in result.output
|
|
|
|
def test_source_add_command_exists(self, runner):
|
|
result = runner.invoke(cli, ["source", "add", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "CONTENT" in result.output
|
|
assert "--type" in result.output
|
|
assert "--notebook" in result.output or "-n" in result.output
|
|
|
|
def test_source_list_command_exists(self, runner):
|
|
result = runner.invoke(cli, ["source", "list", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "--notebook" in result.output or "-n" in result.output
|
|
|
|
def test_source_guide_command_exists(self, runner):
|
|
result = runner.invoke(cli, ["source", "guide", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "SOURCE_ID" in result.output
|
|
assert "--json" in result.output
|
|
|
|
def test_source_stale_command_exists(self, runner):
|
|
result = runner.invoke(cli, ["source", "stale", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "SOURCE_ID" in result.output
|
|
assert "exit code" in result.output.lower()
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE ADD AUTO-DETECT TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceAddAutoDetect:
|
|
def test_source_add_autodetect_file(self, runner, mock_auth, tmp_path):
|
|
"""Pass a real file path without --type; should auto-detect as 'file'."""
|
|
test_file = tmp_path / "notes.txt"
|
|
test_file.write_text("Some file content")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_file = AsyncMock(
|
|
return_value=Source(id="src_file", title="notes.txt")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", str(test_file), "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_file.assert_called_once()
|
|
|
|
def test_source_add_autodetect_plain_text(self, runner, mock_auth):
|
|
"""Pass plain text (not URL, not existing path) without --type.
|
|
|
|
Should auto-detect as 'text' with default title 'Pasted Text'.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "This is just some plain text content", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
# Verify add_text was called with the default "Pasted Text" title
|
|
mock_client.sources.add_text.assert_called_once()
|
|
call_args = mock_client.sources.add_text.call_args
|
|
assert call_args[0][1] == "Pasted Text" # title arg
|
|
|
|
def test_source_add_autodetect_text_with_custom_title(self, runner, mock_auth):
|
|
"""Pass plain text without --type but with --title.
|
|
|
|
Title should be the custom title, not 'Pasted Text'.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Custom Title")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add",
|
|
"This is just some plain text content",
|
|
"--title",
|
|
"Custom Title",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_text.assert_called_once()
|
|
call_args = mock_client.sources.add_text.call_args
|
|
assert call_args[0][1] == "Custom Title" # title arg
|
|
|
|
def test_source_add_file_with_custom_title_passes_title_through(
|
|
self, runner, mock_auth, tmp_path
|
|
):
|
|
"""Regression test for #313: ``--title`` must reach add_file when the
|
|
argument is an existing file path (auto-detected as 'file').
|
|
"""
|
|
test_file = tmp_path / "boring-filename.md"
|
|
test_file.write_text("# content\n")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_file = AsyncMock(
|
|
return_value=Source(id="src_md", title="Real Intended Title")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add",
|
|
str(test_file),
|
|
"--title",
|
|
"Real Intended Title",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_file.assert_called_once()
|
|
call_kwargs = mock_client.sources.add_file.call_args.kwargs
|
|
assert call_kwargs.get("title") == "Real Intended Title"
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE ADD PATH-SHAPED MISSING-FILE WARNING
|
|
# =============================================================================
|
|
#
|
|
# ``notebooklm source add ./missing.md`` historically silently
|
|
# falls through to inline-text ingestion. Users (and AI agents) reading the
|
|
# CLI back-channel cannot distinguish "I sent the literal string ``./missing.md``
|
|
# as note content" from "the file uploaded successfully" — the success line
|
|
# looks identical. The remediation here is a stderr warning when the input
|
|
# *looks like* a path (contains ``/`` OR ends in a known file extension)
|
|
# AND the path does not exist; the source is still added as text so the
|
|
# inferred-text behavior is preserved (no breaking exit-code change). Explicit
|
|
# ``--type text`` suppresses the warning because the user has stated intent
|
|
# unambiguously.
|
|
|
|
|
|
class TestSourceAddPathShapedMissing:
|
|
"""Warn when path-shaped arg doesn't exist on disk."""
|
|
|
|
def test_path_shaped_missing_emits_stderr_warning(self, runner, mock_auth):
|
|
"""``./missing.md`` (slash + known ext, doesn't exist) -> stderr warn.
|
|
|
|
Source is still added as text — warning is advisory, not fatal.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "./missing.md", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
# Source must still be added (advisory warn, not fatal).
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_text.assert_called_once()
|
|
# Click's CliRunner mixes stderr into ``result.output`` by default;
|
|
# both the path-shape phrasing and the original argument should
|
|
# surface so a user can see what triggered the heuristic. Assert
|
|
# the full phrase (not an OR) so a future edit that drops "looks
|
|
# like a path" or "does not exist" individually still trips this.
|
|
assert "looks like a path but does not exist" in result.output
|
|
assert "./missing.md" in result.output
|
|
|
|
def test_path_shaped_missing_with_extension_only_warns(self, runner, mock_auth):
|
|
"""No slash but a known extension still triggers the heuristic.
|
|
|
|
``missing.md`` (no slash, has ``.md``) is path-shaped: ``./missing.md``
|
|
without the leading dot-slash is a common shell mistake.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli, ["source", "add", "missing.md", "-n", "nb_123"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_text.assert_called_once()
|
|
# Tight full-phrase match — see the slash-case test above for rationale.
|
|
assert "looks like a path but does not exist" in result.output
|
|
assert "missing.md" in result.output
|
|
|
|
def test_explicit_type_text_suppresses_warning(self, runner, mock_auth):
|
|
"""``--type text`` is an explicit user override — no warning.
|
|
|
|
The user has stated intent: "treat this as text, not a path." The
|
|
heuristic must respect that and stay silent.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add",
|
|
"./missing.md",
|
|
"--type",
|
|
"text",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_text.assert_called_once()
|
|
assert "looks like a path" not in result.output
|
|
assert "does not exist" not in result.output
|
|
|
|
def test_pure_text_no_path_shape_no_warning(self, runner, mock_auth):
|
|
"""Plain prose with no slash and no known extension -> no warning.
|
|
|
|
Regression guard: the heuristic must not fire on legitimate inline
|
|
text content like ``"My notes here"``.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "This is just some plain prose content", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_text.assert_called_once()
|
|
assert "looks like a path" not in result.output
|
|
assert "does not exist" not in result.output
|
|
|
|
def test_existing_path_no_warning_uploads_as_file(self, runner, mock_auth, tmp_path):
|
|
"""Existing path-shaped arg uploads as ``file`` — no warning emitted.
|
|
|
|
The warning fires only when the path-shape heuristic matches AND
|
|
the file does not exist; an existing file follows the file-upload
|
|
branch as before.
|
|
"""
|
|
test_file = tmp_path / "real.md"
|
|
test_file.write_text("# real content\n")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_file = AsyncMock(
|
|
return_value=Source(id="src_file", title="real.md")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", str(test_file), "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.add_file.assert_called_once()
|
|
assert "looks like a path" not in result.output
|
|
assert "does not exist" not in result.output
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE FULLTEXT TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceFulltext:
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_fulltext_console_output(self, runner, mock_auth, output_mode):
|
|
"""Short content (<= 2000 chars) is displayed in full in both formats."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=SourceFulltext(
|
|
source_id="src_123",
|
|
title="Test Source",
|
|
content="This is the full text content.",
|
|
_type_code=5,
|
|
char_count=30,
|
|
url=None,
|
|
)
|
|
)
|
|
|
|
args = ["source", "fulltext", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
if output_mode == "text":
|
|
assert "src_123" in result.output
|
|
assert "Test Source" in result.output
|
|
assert "This is the full text content." in result.output
|
|
# Should NOT show truncation message for short content
|
|
assert "more chars" not in result.output
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["source_id"] == "src_123"
|
|
assert data["title"] == "Test Source"
|
|
assert data["kind"] == "web_page"
|
|
assert data["content"] == "This is the full text content."
|
|
assert data["char_count"] == 30
|
|
assert "_type_code" not in data
|
|
|
|
def test_source_fulltext_truncated_output(self, runner, mock_auth):
|
|
"""Long content (> 2000 chars) is truncated with a 'more chars' message."""
|
|
long_content = "A" * 3000
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=SourceFulltext(
|
|
source_id="src_123",
|
|
title="Long Source",
|
|
content=long_content,
|
|
char_count=3000,
|
|
url=None,
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "fulltext", "src_123", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "more chars" in result.output
|
|
|
|
def test_source_fulltext_save_to_file(self, runner, mock_auth, tmp_path):
|
|
"""-o flag saves content to file."""
|
|
output_file = tmp_path / "output.txt"
|
|
content = "Full text content to save."
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=SourceFulltext(
|
|
source_id="src_123",
|
|
title="Test Source",
|
|
content=content,
|
|
char_count=len(content),
|
|
url=None,
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "fulltext", "src_123", "-n", "nb_123", "-o", str(output_file)],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Saved" in result.output
|
|
assert output_file.read_text(encoding="utf-8") == content
|
|
|
|
def test_source_fulltext_format_markdown_propagates(self, runner, mock_auth):
|
|
"""`-f markdown` propagates output_format='markdown' to the API."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="MD Source")])
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=SourceFulltext(
|
|
source_id="src_123",
|
|
title="MD Source",
|
|
content="# Heading\n\n[link](https://example.com)",
|
|
char_count=39,
|
|
url=None,
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "fulltext", "src_123", "-n", "nb_123", "-f", "markdown"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_client.sources.get_fulltext.assert_awaited_once()
|
|
_, kwargs = mock_client.sources.get_fulltext.call_args
|
|
assert kwargs["output_format"] == "markdown"
|
|
# Markdown link should round-trip verbatim, not be eaten by Rich markup
|
|
assert "[link](https://example.com)" in result.output
|
|
|
|
def test_source_fulltext_with_url(self, runner, mock_auth):
|
|
"""Shows URL field when present in fulltext."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Web Source")]
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=SourceFulltext(
|
|
source_id="src_123",
|
|
title="Web Source",
|
|
content="Web page content.",
|
|
char_count=17,
|
|
url="https://example.com/page",
|
|
)
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "fulltext", "src_123", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "https://example.com/page" in result.output
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE WAIT TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceWait:
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_wait_success(self, runner, mock_auth, output_mode):
|
|
"""wait_until_ready returns a Source → prints 'ready' / emits ready JSON."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=Source(id="src_123", title="Test Source", status=2)
|
|
)
|
|
|
|
args = ["source", "wait", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 0
|
|
if output_mode == "text":
|
|
assert "ready" in result.output.lower()
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["source_id"] == "src_123"
|
|
assert data["status"] == "ready"
|
|
|
|
def test_source_wait_success_with_title(self, runner, mock_auth):
|
|
"""Source has a title → prints the title after 'ready' message."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="My Source Title")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=Source(id="src_123", title="My Source Title", status=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, ["source", "wait", "src_123", "-n", "nb_123"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "My Source Title" in result.output
|
|
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_wait_not_found(self, runner, mock_auth, output_mode):
|
|
"""SourceNotFoundError → exit 1 (text message / not_found JSON)."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(side_effect=SourceNotFoundError("src_123"))
|
|
|
|
args = ["source", "wait", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 1
|
|
if output_mode == "text":
|
|
assert "not found" in result.output.lower()
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["status"] == "not_found"
|
|
assert data["source_id"] == "src_123"
|
|
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_wait_processing_error(self, runner, mock_auth, output_mode):
|
|
"""SourceProcessingError → exit 1 (text message / error JSON)."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
side_effect=SourceProcessingError("src_123", status=3)
|
|
)
|
|
|
|
args = ["source", "wait", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 1
|
|
if output_mode == "text":
|
|
assert "processing failed" in result.output.lower()
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["status"] == "error"
|
|
assert data["source_id"] == "src_123"
|
|
assert data["status_code"] == 3
|
|
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_wait_timeout(self, runner, mock_auth, output_mode):
|
|
"""SourceTimeoutError → exit 2 (text message / timeout JSON)."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
side_effect=SourceTimeoutError("src_123", timeout=30.0, last_status=1)
|
|
)
|
|
|
|
args = ["source", "wait", "src_123", "-n", "nb_123"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
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, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code == 2
|
|
if output_mode == "text":
|
|
assert "timeout" in result.output.lower()
|
|
else:
|
|
data = json.loads(result.output)
|
|
assert data["status"] == "timeout"
|
|
assert data["source_id"] == "src_123"
|
|
assert data["timeout_seconds"] == 30
|
|
assert data["last_status_code"] == 1
|
|
|
|
def test_source_wait_timeout_interval_forwarded(self, runner, mock_auth):
|
|
"""`source wait <id> --timeout 60 --interval 5` plumbs both into
|
|
wait_until_ready.
|
|
|
|
The `--interval` flag is NEW for `source wait` (previously it accepted
|
|
only `--timeout`). After this change, `source wait` matches the
|
|
uniform `--timeout`/`--interval` surface shared by `artifact wait` and
|
|
`generate <kind> --wait`. The three-way exit policy (0 ready / 1
|
|
not-found-or-error / 2 timeout) is preserved by the success path here
|
|
— exit code stays 0 when the source becomes ready.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=Source(id="src_123", title="Test Source", status=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,
|
|
[
|
|
"source",
|
|
"wait",
|
|
"src_123",
|
|
"-n",
|
|
"nb_123",
|
|
"--timeout",
|
|
"60",
|
|
"--interval",
|
|
"5",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
mock_client.sources.wait_until_ready.assert_awaited_once()
|
|
kwargs = mock_client.sources.wait_until_ready.await_args.kwargs
|
|
assert kwargs.get("timeout") == 60.0
|
|
# `wait_until_ready` exposes the cadence as `initial_interval`.
|
|
assert kwargs.get("initial_interval") == 5.0, (
|
|
f"expected --interval=5 to plumb into wait_until_ready, got kwargs={kwargs}"
|
|
)
|
|
|
|
def test_source_wait_invokes_console_status(self, runner, mock_auth):
|
|
"""`source wait` wraps the polling call in `console.status`.
|
|
|
|
The spinner replaces the static "Waiting for source ..." print with a
|
|
live transient line that includes the source ID. Asserts the wrap by
|
|
patching `notebooklm.cli.source_cmd.console.status` and confirming it is
|
|
invoked exactly once with a message that mentions the source. 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.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=Source(id="src_123", title="Test Source", status=2)
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch,
|
|
patch.object(source_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, ["source", "wait", "src_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 "source" in status_msg.lower() or "src_123" in status_msg, (
|
|
f"expected status message to describe the source wait, got: {status_msg!r}"
|
|
)
|
|
|
|
def test_source_wait_json_skips_console_status(self, runner, mock_auth):
|
|
"""`source 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.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=Source(id="src_123", title="Test Source", status=2)
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch,
|
|
patch.object(source_module.console, "status") as mock_status,
|
|
):
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "wait", "src_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_source_wait_sigint_prints_resume_hint_and_exits_130(self, runner, mock_auth):
|
|
"""Ctrl-C during ``source wait`` exits 130 with a parallel resume hint
|
|
naming the source id.
|
|
|
|
Sources have no separate ``poll`` command — re-running the same wait
|
|
IS the resume — so the hint shape is
|
|
``Cancelled. Resume with: notebooklm source wait <source_id>`` rather
|
|
than the ``artifact poll <task_id>`` shape used by the other two
|
|
long-running paths.
|
|
"""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_sigint", title="Test Source")]
|
|
)
|
|
mock_client.sources.wait_until_ready = 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,
|
|
["source", "wait", "src_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 source wait src_sigint" in combined, (
|
|
f"expected parallel resume hint with source id; got: {combined!r}"
|
|
)
|
|
|
|
def test_source_wait_sigint_json_emits_cancelled_envelope(self, runner, mock_auth):
|
|
"""Ctrl-C under ``source wait --json`` emits a CANCELLED envelope, exits 130
|
|
."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_json_sigint", title="T")])
|
|
mock_client.sources.wait_until_ready = 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,
|
|
["source", "wait", "src_json_sigint", "-n", "nb_123", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 130
|
|
assert '"code": "CANCELLED"' in result.output
|
|
assert "notebooklm source wait src_json_sigint" in result.output
|
|
|
|
|
|
# =============================================================================
|
|
# SOURCE CLEAN TESTS
|
|
# =============================================================================
|
|
|
|
|
|
def _src(
|
|
sid: str,
|
|
*,
|
|
title: str | None = None,
|
|
url: str | None = None,
|
|
status: int = SourceStatus.READY,
|
|
created_at: datetime | None = None,
|
|
) -> Source:
|
|
"""Build a Source fixture with sensible defaults for clean tests."""
|
|
return Source(id=sid, title=title, url=url, status=status, created_at=created_at)
|
|
|
|
|
|
# NOTE: The pure-function ``_classify_junk_sources`` branch tests (formerly
|
|
# ``TestSourceCleanClassify``) were moved down to the transport-neutral layer in
|
|
# ``tests/unit/app/test_app_source_clean.py::TestClassifyJunkSources`` — they
|
|
# already called the pure function directly through the
|
|
# ``source_cmd._classify_junk_sources`` re-export, so the move only retargets the
|
|
# import. The end-to-end Click invocation tests stay here.
|
|
|
|
|
|
class TestSourceCleanCommand:
|
|
"""End-to-end Click invocation tests for the ``source clean`` command."""
|
|
|
|
def _patch_clean(self, sources):
|
|
"""Return a context manager that wires up a mock client returning sources."""
|
|
return _CleanPatch(sources)
|
|
|
|
def test_already_clean_short_circuits(self, runner, mock_auth):
|
|
with self._patch_clean([_src("src_1", title="Page", url="https://ex.com/a")]) as mc:
|
|
result = runner.invoke(cli, ["source", "clean", "-n", "nb_123", "-y"], obj=mc.obj)
|
|
assert result.exit_code == 0
|
|
assert "already clean" in result.output.lower()
|
|
mc.sources.delete.assert_not_called()
|
|
|
|
def test_dry_run_shows_table_and_skips_delete(self, runner, mock_auth):
|
|
sources = [
|
|
_src("src_err", title="oops", status=SourceStatus.ERROR),
|
|
_src("src_block", title="Just a Moment...", url="https://ex.com/x"),
|
|
]
|
|
with self._patch_clean(sources) as mc:
|
|
result = runner.invoke(
|
|
cli, ["source", "clean", "-n", "nb_123", "--dry-run"], obj=mc.obj
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Dry run" in result.output
|
|
assert "error_status" in result.output
|
|
assert "gateway_title" in result.output
|
|
mc.sources.delete.assert_not_called()
|
|
|
|
def test_yes_skips_confirmation_and_deletes(self, runner, mock_auth):
|
|
sources = [_src("src_err", status=SourceStatus.ERROR)]
|
|
with self._patch_clean(sources) as mc:
|
|
result = runner.invoke(cli, ["source", "clean", "-n", "nb_123", "-y"], obj=mc.obj)
|
|
assert result.exit_code == 0
|
|
mc.sources.delete.assert_awaited_once_with("nb_123", "src_err")
|
|
assert "Successfully cleaned" in result.output
|
|
|
|
def test_user_declines_confirmation_aborts(self, runner, mock_auth):
|
|
sources = [_src("src_err", status=SourceStatus.ERROR)]
|
|
with self._patch_clean(sources) as mc:
|
|
result = runner.invoke(
|
|
cli, ["source", "clean", "-n", "nb_123"], input="n\n", obj=mc.obj
|
|
)
|
|
assert result.exit_code == 0
|
|
mc.sources.delete.assert_not_called()
|
|
|
|
def test_partial_failure_reports_failing_ids(self, runner, mock_auth):
|
|
sources = [
|
|
_src("src_a", status=SourceStatus.ERROR),
|
|
_src("src_b", status=SourceStatus.ERROR),
|
|
]
|
|
|
|
async def fake_delete(nb, sid):
|
|
if sid == "src_b":
|
|
raise RuntimeError("boom")
|
|
|
|
with self._patch_clean(sources) as mc:
|
|
mc.sources.delete = AsyncMock(side_effect=fake_delete)
|
|
result = runner.invoke(cli, ["source", "clean", "-n", "nb_123", "-y"], obj=mc.obj)
|
|
# P1.T2 bug 8: partial-failure must exit non-zero so shell
|
|
# automation (set -e, CI, etc.) can detect the failure.
|
|
assert result.exit_code != 0
|
|
assert "1 deletion(s) failed" in result.output
|
|
assert "src_b" in result.output
|
|
assert "boom" in result.output
|
|
|
|
|
|
class _CleanPatch:
|
|
"""Context manager that wires a mock NotebookLMClient for source-clean tests.
|
|
|
|
The client is injected via ``ctx.obj`` (``self.obj``), so each test threads
|
|
``obj=mc.obj`` into ``runner.invoke``. Uses ``ExitStack`` so the
|
|
``fetch_tokens`` patch correctly unwinds even if setup raises mid-way.
|
|
"""
|
|
|
|
def __init__(self, sources):
|
|
self._sources = sources
|
|
self._exit_stack = contextlib.ExitStack()
|
|
|
|
def __enter__(self):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=self._sources)
|
|
mock_client.sources.delete = AsyncMock(return_value=None)
|
|
self.sources = mock_client.sources
|
|
self.obj = inject_client(mock_client)
|
|
|
|
fetch_mock = self._exit_stack.enter_context(
|
|
patch.object(auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock)
|
|
)
|
|
fetch_mock.return_value = ("csrf", "session")
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return self._exit_stack.__exit__(*exc)
|
|
|
|
|
|
# =============================================================================
|
|
# JSON OUTPUT SMOKE TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceJsonOutput:
|
|
"""Smoke coverage for ``--json`` on the eight source subcommands:
|
|
delete, rename, refresh, clean, get, delete-by-title, add-drive, stale.
|
|
|
|
These tests exercise the JSON branch end-to-end through Click and assert:
|
|
|
|
1. Stdout is parseable JSON (no Rich color codes leaking onto stdout).
|
|
2. The shape exposes the fields automation needs (``source_id``,
|
|
``status``, etc.).
|
|
3. ``source stale --json`` follows the standard CLI exit convention
|
|
(0=success regardless of freshness, 1=error). The inverted
|
|
predicate is available as an opt-in via ``--exit-on-stale``;
|
|
see ``docs/cli-exit-codes.md``.
|
|
"""
|
|
|
|
def _patch_fetch_tokens(self):
|
|
return patch.object(
|
|
auth_module,
|
|
"fetch_tokens_with_domains",
|
|
new_callable=AsyncMock,
|
|
return_value=("csrf", "session"),
|
|
)
|
|
|
|
def test_source_get_json(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="My Source")])
|
|
mock_client.sources.get_or_none = AsyncMock(
|
|
return_value=Source(
|
|
id="src_123",
|
|
title="My Source",
|
|
url="https://example.com",
|
|
_type_code=5,
|
|
created_at=datetime(2024, 1, 1, 12, 0),
|
|
)
|
|
)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "get", "src_123", "-n", "nb_123", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["found"] is True
|
|
assert data["source"]["id"] == "src_123"
|
|
assert data["source"]["title"] == "My Source"
|
|
assert data["source"]["type"] == "web_page"
|
|
assert data["source"]["url"] == "https://example.com"
|
|
assert data["source"]["created_at"] == "2024-01-01T12:00:00"
|
|
|
|
def test_source_get_json_not_found_exits_1_with_typed_json(self, runner, mock_auth):
|
|
# The contract was flipped: ``get`` on not-found now exits 1
|
|
# and emits the standard typed JSON error envelope (``{error, code,
|
|
# message}``) instead of the previous exit-0 ``{found: false}``
|
|
# placeholder. See ``docs/cli-exit-codes.md`` and the BREAKING entry
|
|
# in ``CHANGELOG.md`` (Unreleased → Changed).
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_resolved", title="Existing")]
|
|
)
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=None)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "get", "src_resolved", "-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 "Source not found" in data["message"]
|
|
assert data["source_id"] == "src_resolved"
|
|
|
|
def test_source_delete_json(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="My Source")])
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "src_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["action"] == "delete"
|
|
assert data["source_id"] == "src_123"
|
|
assert data["notebook_id"] == "nb_123"
|
|
assert data["success"] is True
|
|
assert data["status"] == "deleted"
|
|
|
|
def test_source_delete_by_title_json(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_999", title="Doomed")])
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"delete-by-title",
|
|
"Doomed",
|
|
"-n",
|
|
"nb_123",
|
|
"-y",
|
|
"--json",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["action"] == "delete-by-title"
|
|
assert data["source_id"] == "src_999"
|
|
assert data["title"] == "Doomed"
|
|
assert data["success"] is True
|
|
assert data["status"] == "deleted"
|
|
|
|
def test_source_rename_json(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="Old")])
|
|
mock_client.sources.rename = AsyncMock(return_value=Source(id="src_123", title="New"))
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "rename", "src_123", "New", "-n", "nb_123", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["action"] == "rename"
|
|
assert data["source_id"] == "src_123"
|
|
assert data["title"] == "New"
|
|
assert data["status"] == "renamed"
|
|
|
|
def test_source_refresh_json(self, runner, mock_auth):
|
|
# v0.8.0 (#1290): refresh() returns None on success; the --json path must
|
|
# render status "refreshed" (keyed on the resolved source id).
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="Original")])
|
|
mock_client.sources.refresh = AsyncMock(return_value=None)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "refresh", "src_123", "-n", "nb_123", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["action"] == "refresh"
|
|
assert data["source_id"] == "src_123"
|
|
assert data["status"] == "refreshed"
|
|
|
|
def test_source_refresh_json_none_is_refreshed_not_no_result(self, runner, mock_auth):
|
|
# Regression guard for the hidden CLI bug (#1290): once refresh() returns
|
|
# None on success, the --json path must NOT fall through to "no_result".
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="Original")])
|
|
mock_client.sources.refresh = AsyncMock(return_value=None)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "refresh", "src_123", "-n", "nb_123", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["action"] == "refresh"
|
|
assert data["source_id"] == "src_123"
|
|
assert data["status"] == "refreshed"
|
|
assert data["status"] != "no_result"
|
|
|
|
def test_source_add_drive_json(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_drive = AsyncMock(
|
|
return_value=Source(id="src_drive", title="My Drive Doc", _type_code=3)
|
|
)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-drive",
|
|
"drive_file_xyz",
|
|
"My Drive Doc",
|
|
"--mime-type",
|
|
"pdf",
|
|
"-n",
|
|
"nb_123",
|
|
"--json",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["action"] == "add-drive"
|
|
assert data["source"]["id"] == "src_drive"
|
|
assert data["source"]["title"] == "My Drive Doc"
|
|
assert data["source"]["type"] == "pdf"
|
|
assert data["source"]["drive_file_id"] == "drive_file_xyz"
|
|
assert data["source"]["mime_type"] == "pdf"
|
|
assert data["notebook_id"] == "nb_123"
|
|
|
|
def test_source_clean_json_already_clean(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[_src("src_1", title="Page", url="https://ex.com/a")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=None)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "clean", "-n", "nb_123", "-y", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["action"] == "clean"
|
|
assert data["status"] == "already_clean"
|
|
assert data["candidates"] == []
|
|
assert data["deleted_count"] == 0
|
|
|
|
def test_source_clean_json_dry_run(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[_src("src_err", title="oops", status=SourceStatus.ERROR)]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=None)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "clean", "-n", "nb_123", "--dry-run", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["status"] == "dry_run"
|
|
assert data["candidate_count"] == 1
|
|
assert data["candidates"][0]["id"] == "src_err"
|
|
assert data["candidates"][0]["reason"] == "error_status"
|
|
assert data["deleted_count"] == 0
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_clean_json_completed(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[_src("src_err", title="oops", status=SourceStatus.ERROR)]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "clean", "-n", "nb_123", "-y", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["status"] == "completed"
|
|
assert data["deleted_count"] == 1
|
|
assert data["failure_count"] == 0
|
|
assert data["failures"] == []
|
|
mock_client.sources.delete.assert_awaited_once_with("nb_123", "src_err")
|
|
|
|
|
|
# =============================================================================
|
|
# Stdin (`-`) convention for ``source add``
|
|
# =============================================================================
|
|
#
|
|
# Unix tradition: ``source add -`` reads source content from stdin and
|
|
# ingests it as inline text. This is the natural pipeline for ``cat
|
|
# content.txt | notebooklm source add -`` or ``some-cmd | notebooklm
|
|
# source add -``. The literal ``-`` is intercepted BEFORE auto-detection
|
|
# so it never accidentally falls into the path-shaped warning branch.
|
|
|
|
|
|
class TestSourceAddStdinDash:
|
|
"""``notebooklm source add -`` reads inline text from stdin."""
|
|
|
|
def test_source_add_dash_reads_stdin(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "-", "-n", "nb_123"],
|
|
input="content from stdin\n",
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
mock_client.sources.add_text.assert_awaited_once()
|
|
call = mock_client.sources.add_text.call_args
|
|
# signature: add_text(notebook_id, title, content)
|
|
assert call.args[0] == "nb_123"
|
|
assert call.args[2] == "content from stdin"
|
|
|
|
def test_source_add_dash_with_title(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="My Title")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "-", "--title", "My Title", "-n", "nb_123"],
|
|
input="hello world\n",
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
call = mock_client.sources.add_text.call_args
|
|
assert call.args[1] == "My Title"
|
|
assert call.args[2] == "hello world"
|
|
|
|
def test_source_add_dash_with_explicit_text_type_reads_stdin(self, runner, mock_auth):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "-", "--type", "text", "-n", "nb_123"],
|
|
input="typed stdin\n",
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
mock_client.sources.add_text.assert_awaited_once()
|
|
call = mock_client.sources.add_text.call_args
|
|
assert call.args[2] == "typed stdin"
|
|
|
|
@pytest.mark.parametrize("source_type", ["url", "file", "youtube"])
|
|
def test_source_add_dash_rejects_non_text_type(self, runner, mock_auth, source_type):
|
|
client_calls: list = []
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "-", "--type", source_type, "-n", "nb_123"],
|
|
input="content from stdin\n",
|
|
obj=inject_client(create_mock_client(), recorder=client_calls),
|
|
)
|
|
|
|
assert result.exit_code == 2
|
|
assert f"Cannot use '-' (stdin) with --type {source_type}" in result.output
|
|
assert client_calls == []
|
|
|
|
def test_source_add_dash_rejects_non_text_type_json(self, runner, mock_auth):
|
|
client_calls: list = []
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "-", "--type", "url", "-n", "nb_123", "--json"],
|
|
input="content from stdin\n",
|
|
obj=inject_client(create_mock_client(), recorder=client_calls),
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
data = json.loads(result.output)
|
|
assert data == {
|
|
"error": True,
|
|
"code": "VALIDATION_ERROR",
|
|
"message": (
|
|
"Cannot use '-' (stdin) with --type url; stdin content can only be added as text."
|
|
),
|
|
}
|
|
assert client_calls == []
|
|
|
|
def test_source_add_literal_dash_path_unchanged(self, runner, mock_auth):
|
|
"""Regression: a normal text argument is not treated as stdin."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=Source(id="src_text", title="Pasted Text")
|
|
)
|
|
|
|
with patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "literal text", "-n", "nb_123"],
|
|
input="ignored stdin\n",
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
call = mock_client.sources.add_text.call_args
|
|
assert call.args[2] == "literal text"
|
|
|
|
|
|
# =============================================================================
|
|
# P1.T2 — source.py surgical bug bundle (CLI audit fixes)
|
|
# =============================================================================
|
|
#
|
|
# Eight regression tests, one per bug in the audit's P1.T2 bundle. Each test
|
|
# pins the *fixed* contract; on `main` (pre-fix) every test in this class
|
|
# should be red.
|
|
#
|
|
# Bug index:
|
|
# 1. source delete --json without --yes -> structured JSON error, no prompt.
|
|
# 2. source delete-by-title --json without --yes -> same.
|
|
# 3. source clean --json without --yes (with candidates) -> structured error.
|
|
# 4. source fulltext --json -o FILE -> file written + metadata envelope on stdout.
|
|
# 5. source add -> console.status spinner brackets the awaited upload, not the
|
|
# pre-await coroutine creation.
|
|
# 6. source add-research -> client.research.poll is task-pinned via task_id.
|
|
# 7. source add-research --no-wait --import-all -> UsageError (exit 2).
|
|
# 8. source clean -> exit code != 0 when any deletion fails.
|
|
|
|
|
|
class TestSourceBundleP1T2:
|
|
"""Regression tests for the P1.T2 source.py bug bundle (CLI audit)."""
|
|
|
|
def _patch_fetch_tokens(self):
|
|
return patch.object(
|
|
auth_module,
|
|
"fetch_tokens_with_domains",
|
|
new_callable=AsyncMock,
|
|
return_value=("csrf", "session"),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 1: source delete --json without --yes
|
|
# ------------------------------------------------------------------
|
|
def test_source_delete_json_without_yes_emits_structured_error_no_prompt(
|
|
self, runner, mock_auth
|
|
):
|
|
"""`source delete <id> --json` without `--yes` must NOT prompt; instead
|
|
emit a structured JSON error and exit non-zero."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="My Source")])
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with self._patch_fetch_tokens(), patch("click.confirm") as mock_confirm:
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "delete", "src_123", "-n", "nb_123", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code != 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "CONFIRM_REQUIRED"
|
|
assert "--yes" in data["message"]
|
|
mock_confirm.assert_not_called()
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 2: source delete-by-title --json without --yes
|
|
# ------------------------------------------------------------------
|
|
def test_source_delete_by_title_json_without_yes_emits_structured_error_no_prompt(
|
|
self, runner, mock_auth
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Test Source")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with self._patch_fetch_tokens(), patch("click.confirm") as mock_confirm:
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"delete-by-title",
|
|
"Test Source",
|
|
"-n",
|
|
"nb_123",
|
|
"--json",
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code != 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "CONFIRM_REQUIRED"
|
|
assert "--yes" in data["message"]
|
|
mock_confirm.assert_not_called()
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 3: source clean --json without --yes with candidates
|
|
# ------------------------------------------------------------------
|
|
def test_source_clean_json_without_yes_with_candidates_emits_structured_error_no_prompt(
|
|
self, runner, mock_auth
|
|
):
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[_src("src_err", title="oops", status=SourceStatus.ERROR)]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=True)
|
|
|
|
with self._patch_fetch_tokens(), patch("click.confirm") as mock_confirm:
|
|
result = runner.invoke(
|
|
cli, ["source", "clean", "-n", "nb_123", "--json"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code != 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "CONFIRM_REQUIRED"
|
|
assert "--yes" in data["message"]
|
|
mock_confirm.assert_not_called()
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
def test_source_clean_json_already_clean_without_yes_does_not_error(self, runner, mock_auth):
|
|
"""`source clean --json` without --yes must NOT error when there are no
|
|
candidates — the confirmation is a no-op in that path."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[_src("src_1", title="Page", url="https://ex.com/a")]
|
|
)
|
|
mock_client.sources.delete = AsyncMock(return_value=None)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli, ["source", "clean", "-n", "nb_123", "--json"], obj=inject_client(mock_client)
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["status"] == "already_clean"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 4: source fulltext --json -o FILE -> metadata envelope on stdout
|
|
# ------------------------------------------------------------------
|
|
def test_source_fulltext_json_with_output_file_writes_file_and_emits_metadata(
|
|
self, runner, mock_auth, tmp_path
|
|
):
|
|
"""`source fulltext --json -o FILE` writes the full content to FILE and
|
|
emits a metadata envelope on stdout (NOT the full content twice)."""
|
|
output_file = tmp_path / "fulltext.txt"
|
|
body = "A" * 1024 # large enough to make the duplication smell obvious
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[Source(id="src_123", title="Big Source")]
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=SourceFulltext(
|
|
source_id="src_123",
|
|
title="Big Source",
|
|
content=body,
|
|
_type_code=5,
|
|
char_count=len(body),
|
|
url=None,
|
|
)
|
|
)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"fulltext",
|
|
"src_123",
|
|
"-n",
|
|
"nb_123",
|
|
"--json",
|
|
"-o",
|
|
str(output_file),
|
|
],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# File written with full content (UTF-8).
|
|
assert output_file.read_text(encoding="utf-8") == body
|
|
# Stdout is a metadata envelope, NOT the full content.
|
|
data = json.loads(result.output)
|
|
assert data["path"] == str(output_file)
|
|
assert data["bytes"] == len(body.encode("utf-8"))
|
|
assert data["source_id"] == "src_123"
|
|
assert data["title"] == "Big Source"
|
|
assert data["kind"] == "web_page"
|
|
# Full content must not be in the metadata envelope.
|
|
assert "content" not in data
|
|
|
|
def test_source_fulltext_json_without_output_file_keeps_full_payload(self, runner, mock_auth):
|
|
"""Regression guard: when `-o` is OMITTED, `--json` mode still emits the
|
|
full public SourceFulltext payload on stdout."""
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=[Source(id="src_123", title="Tiny")])
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=SourceFulltext(
|
|
source_id="src_123",
|
|
title="Tiny",
|
|
content="hello",
|
|
_type_code=5,
|
|
char_count=5,
|
|
url=None,
|
|
)
|
|
)
|
|
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "fulltext", "src_123", "-n", "nb_123", "--json"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["kind"] == "web_page"
|
|
assert data["content"] == "hello"
|
|
assert data["char_count"] == 5
|
|
assert "_type_code" not in data
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 5: source add spinner brackets the awaited upload
|
|
# ------------------------------------------------------------------
|
|
def test_source_add_spinner_brackets_upload(self, runner, mock_auth):
|
|
"""`source add <url>` non-JSON mode wraps the awaited upload in
|
|
`console.status(...)`, NOT just the synchronous coroutine creation.
|
|
|
|
The pre-fix code did ``with console.status(...): return _run()`` — the
|
|
``with`` block exits as soon as ``_run()`` returns the coroutine,
|
|
BEFORE the coroutine is awaited. We assert spinner enter happens before
|
|
the upload mock is awaited and spinner exit happens after.
|
|
"""
|
|
timeline: list[str] = []
|
|
|
|
class _RecordingStatus:
|
|
def __init__(self, message):
|
|
self.message = message
|
|
|
|
def __enter__(self):
|
|
timeline.append("status.enter")
|
|
return MagicMock()
|
|
|
|
def __exit__(self, *exc):
|
|
timeline.append("status.exit")
|
|
return False
|
|
|
|
def _make_status(message, *args, **kwargs):
|
|
return _RecordingStatus(message)
|
|
|
|
async def _record_add_url(*args, **kwargs):
|
|
timeline.append("upload.start")
|
|
return Source(id="src_new", title="Added", url="https://ex.com")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.add_url = AsyncMock(side_effect=_record_add_url)
|
|
|
|
with (
|
|
self._patch_fetch_tokens(),
|
|
patch.object(source_module.console, "status", side_effect=_make_status),
|
|
):
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add", "https://ex.com", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# The spinner must enclose the upload: enter -> upload -> exit.
|
|
assert timeline == ["status.enter", "upload.start", "status.exit"], (
|
|
f"spinner must bracket the awaited upload; got timeline={timeline}"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 6: source add-research polling must pin to task_id
|
|
# ------------------------------------------------------------------
|
|
def test_source_add_research_poll_is_task_pinned(self, runner, mock_auth):
|
|
"""`source add-research` must pass ``task_id`` to ``client.research.poll``
|
|
so a second research task starting mid-poll cannot cross-wire its
|
|
sources into this task's import branch."""
|
|
mock_client = create_mock_client()
|
|
mock_client.research.start = AsyncMock(
|
|
return_value=research_start({"task_id": "task_pinned"})
|
|
)
|
|
# First poll returns in_progress (so the loop continues at least once
|
|
# and we can prove the discriminator is threaded); second returns
|
|
# completed.
|
|
poll_calls: list[dict] = []
|
|
|
|
async def _poll(notebook_id, task_id=None):
|
|
poll_calls.append({"notebook_id": notebook_id, "task_id": task_id})
|
|
if len(poll_calls) < 2:
|
|
return research_task({"status": "in_progress", "task_id": "task_pinned"})
|
|
return research_task(
|
|
{
|
|
"status": "completed",
|
|
"task_id": "task_pinned",
|
|
"sources": [],
|
|
"report": "",
|
|
}
|
|
)
|
|
|
|
mock_client.research.poll = AsyncMock(side_effect=_poll)
|
|
|
|
# Short-circuit asyncio.sleep so the test does not wait 5s between polls.
|
|
# Use patch.object on the already-imported module to avoid the dotted-string
|
|
# path "notebooklm.cli.source_cmd.asyncio.sleep" which fails on Python 3.10 because
|
|
# mock.patch tries to import "notebooklm.cli.source_cmd" as a package first.
|
|
with (
|
|
self._patch_fetch_tokens(),
|
|
patch.object(source_module.asyncio, "sleep", AsyncMock()),
|
|
):
|
|
result = runner.invoke(
|
|
cli,
|
|
["source", "add-research", "topic", "-n", "nb_123"],
|
|
obj=inject_client(mock_client),
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert len(poll_calls) >= 2
|
|
# Every poll call after the first must carry the task_id
|
|
# discriminator. (The first call may be unpinned if implementation
|
|
# discovers the id from start(); the second MUST be pinned.)
|
|
assert poll_calls[1]["task_id"] == "task_pinned", (
|
|
f"second poll must pass task_id='task_pinned'; got {poll_calls[1]!r}"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 7: --no-wait + --import-all is a usage error
|
|
# ------------------------------------------------------------------
|
|
def test_source_add_research_no_wait_with_import_all_is_usage_error(
|
|
self, runner, mock_auth, mock_fetch_tokens
|
|
):
|
|
result = runner.invoke(
|
|
cli,
|
|
[
|
|
"source",
|
|
"add-research",
|
|
"topic",
|
|
"--no-wait",
|
|
"--import-all",
|
|
"-n",
|
|
"nb_123",
|
|
],
|
|
)
|
|
|
|
# ``click.UsageError`` exits 2 — Click's standard convention.
|
|
assert result.exit_code == 2, result.output
|
|
assert "--import-all" in result.output
|
|
assert "--no-wait" in result.output or "--wait" in result.output
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bug 8: source clean partial-failure exit code
|
|
# ------------------------------------------------------------------
|
|
@pytest.mark.parametrize("output_mode", ["text", "json"])
|
|
def test_source_clean_partial_failure_exits_nonzero(self, runner, mock_auth, output_mode):
|
|
sources = [
|
|
_src("src_a", status=SourceStatus.ERROR),
|
|
_src("src_b", status=SourceStatus.ERROR),
|
|
]
|
|
|
|
async def fake_delete(nb, sid):
|
|
if sid == "src_b":
|
|
raise RuntimeError("boom")
|
|
|
|
mock_client = create_mock_client()
|
|
mock_client.sources.list = AsyncMock(return_value=sources)
|
|
mock_client.sources.delete = AsyncMock(side_effect=fake_delete)
|
|
|
|
args = ["source", "clean", "-n", "nb_123", "-y"]
|
|
if output_mode == "json":
|
|
args.append("--json")
|
|
with self._patch_fetch_tokens():
|
|
result = runner.invoke(cli, args, obj=inject_client(mock_client))
|
|
|
|
assert result.exit_code != 0, result.output
|
|
if output_mode == "text":
|
|
assert "1 deletion(s) failed" in result.output
|
|
assert "src_b" in result.output
|
|
assert "boom" in result.output
|
|
else:
|
|
data = json.loads(result.output)
|
|
# Behavior parity with text mode: the JSON envelope still carries
|
|
# the full clean report so callers can introspect which IDs failed.
|
|
assert data["status"] == "completed"
|
|
assert data["deleted_count"] == 1
|
|
assert data["failure_count"] == 1
|
|
assert data["failures"] == [{"id": "src_b", "error": "boom"}]
|