Files
teng-lin--notebooklm-py/tests/unit/cli/test_source_characterization.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

567 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Characterization tests for ``notebooklm source ...`` CLI commands.
These tests pin observable CLI behavior across the ``source`` command surface
BEFORE the P3.T5 service extraction. They are end-to-end at the ``CliRunner``
level so they capture exit codes, stdout/stderr structure, and JSON envelope
shape that lower-level service-unit tests in ``test_source.py`` do not cover
holistically.
They MUST pass identically on ``main`` HEAD before the extraction commit
lands, and they MUST continue to pass byte-for-byte after the extraction.
Any divergence is a behavior regression — not an opportunity to "fix" the
old behavior.
Coverage matrix (commands × output modes from the P3.T5 spec):
| Command | text | json |
|--------------------------|------|------|
| source add | yes | yes |
| source list | yes | yes |
| source get | yes | yes |
| source delete | yes | yes |
| source delete-by-title | yes | yes |
| source clean | yes | yes |
| source fulltext | yes | yes |
| source guide | yes | yes |
| source add-research | yes | yes |
| source wait | yes | yes |
Each test pins one (command, mode) cell of the matrix to a specific
expected-text fragment or JSON envelope. The fragments are intentionally
narrow (one or two structural lines) so the snapshots survive cosmetic
changes (e.g. Rich color codes) while still catching shape regressions.
"""
from __future__ import annotations
import json
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
import notebooklm.cli.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, source_guide
pytestmark = pytest.mark.characterization
# ----------------------------------------------------------------------------
# Fixtures
# ----------------------------------------------------------------------------
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def mock_auth():
with patch.object(helpers_module, "load_auth_from_storage") as m:
m.return_value = {
"SID": "test",
"__Secure-1PSIDTS": "test_1psidts",
"HSID": "test",
"SSID": "test",
"APISID": "test",
"SAPISID": "test",
}
yield m
@pytest.fixture
def patched_fetch_tokens():
with patch.object(auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock) as fetch:
fetch.return_value = ("csrf", "session")
yield fetch
# ----------------------------------------------------------------------------
# source add
# ----------------------------------------------------------------------------
class TestSourceAddCharacterization:
def test_add_url_text_mode_prints_added_source_line(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
client.sources.add_url = AsyncMock(
return_value=Source(id="src_new_url", title="URL Source", url="https://x")
)
result = runner.invoke(
cli, ["source", "add", "https://example.com", "-n", "nb_123"], obj=inject_client(client)
)
assert result.exit_code == 0
assert "Added source:" in result.output
assert "src_new_url" in result.output
def test_add_url_json_mode_emits_source_envelope(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.add_url = AsyncMock(
return_value=Source(id="src_new", title="T", url="https://x", _type_code=5)
)
result = runner.invoke(
cli,
["source", "add", "https://example.com", "-n", "nb_123", "--json"],
obj=inject_client(client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["source"]["id"] == "src_new"
assert payload["source"]["title"] == "T"
assert payload["source"]["type"] == "web_page"
assert payload["source"]["url"] == "https://x"
# ----------------------------------------------------------------------------
# source list
# ----------------------------------------------------------------------------
class TestSourceListCharacterization:
def test_list_text_mode_renders_rich_table(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[Source(id="src_1", title="One")])
result = runner.invoke(cli, ["source", "list", "-n", "nb_123"], obj=inject_client(client))
assert result.exit_code == 0
assert "Sources in nb_123" in result.output
assert "src_1" in result.output
assert "One" in result.output
def test_list_json_mode_emits_array_with_count(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(
return_value=[
Source(
id="src_1",
title="One",
url=None,
_type_code=5,
status=SourceStatus.READY,
created_at=datetime(2025, 1, 1, 12, 0, 0),
)
]
)
client.notebooks.get = AsyncMock(return_value=type("N", (), {"title": "NB"})())
result = runner.invoke(
cli, ["source", "list", "-n", "nb_123", "--json"], obj=inject_client(client)
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["notebook_id"] == "nb_123"
assert payload["count"] == 1
assert payload["sources"][0]["id"] == "src_1"
assert payload["sources"][0]["title"] == "One"
assert payload["sources"][0]["type"] == "web_page"
# ----------------------------------------------------------------------------
# source get
# ----------------------------------------------------------------------------
class TestSourceGetCharacterization:
def test_get_text_mode_prints_title_and_type(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.get_or_none = AsyncMock(
return_value=Source(id="src_1", title="My Source", url=None)
)
result = runner.invoke(
cli, ["source", "get", "src_1", "-n", "nb_123"], obj=inject_client(client)
)
assert result.exit_code == 0
assert "Source:" in result.output
assert "src_1" in result.output
assert "My Source" in result.output
def test_get_json_mode_emits_source_envelope(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.get_or_none = AsyncMock(
return_value=Source(
id="src_1",
title="My Source",
url="https://x",
_type_code=5,
status=SourceStatus.READY,
)
)
result = runner.invoke(
cli, ["source", "get", "src_1", "-n", "nb_123", "--json"], obj=inject_client(client)
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["found"] is True
assert payload["source"]["id"] == "src_1"
assert payload["source"]["type"] == "web_page"
assert payload["source"]["url"] == "https://x"
def test_get_not_found_exits_1_text_mode(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.get_or_none = AsyncMock(return_value=None)
result = runner.invoke(
cli,
[
"source",
"get",
"11111111-2222-3333-4444-555555555555",
"-n",
"nb_123",
],
obj=inject_client(client),
)
assert result.exit_code == 1
def test_get_not_found_exits_1_json_mode(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.get_or_none = AsyncMock(return_value=None)
result = runner.invoke(
cli,
[
"source",
"get",
"11111111-2222-3333-4444-555555555555",
"-n",
"nb_123",
"--json",
],
obj=inject_client(client),
)
assert result.exit_code == 1
payload = json.loads(result.output)
assert payload["error"] is True
assert payload["code"] == "NOT_FOUND"
# ----------------------------------------------------------------------------
# source delete
# ----------------------------------------------------------------------------
class TestSourceDeleteCharacterization:
def test_delete_text_mode_with_yes_prints_deleted(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
client.sources.delete = AsyncMock(return_value=True)
result = runner.invoke(
cli, ["source", "delete", "src_1", "-n", "nb_123", "-y"], obj=inject_client(client)
)
assert result.exit_code == 0
assert "Deleted source:" in result.output
def test_delete_json_mode_with_yes_emits_envelope(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
client.sources.delete = AsyncMock(return_value=True)
result = runner.invoke(
cli,
["source", "delete", "src_1", "-n", "nb_123", "-y", "--json"],
obj=inject_client(client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["action"] == "delete"
assert payload["source_id"] == "src_1"
assert payload["success"] is True
assert payload["status"] == "deleted"
# ----------------------------------------------------------------------------
# source delete-by-title
# ----------------------------------------------------------------------------
class TestSourceDeleteByTitleCharacterization:
def test_delete_by_title_text_mode_with_yes(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[Source(id="src_999", title="Exact Title")])
client.sources.delete = AsyncMock(return_value=True)
result = runner.invoke(
cli,
[
"source",
"delete-by-title",
"Exact Title",
"-n",
"nb_123",
"-y",
],
obj=inject_client(client),
)
assert result.exit_code == 0
assert "Deleted source:" in result.output
def test_delete_by_title_json_mode_with_yes(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[Source(id="src_999", title="Exact Title")])
client.sources.delete = AsyncMock(return_value=True)
result = runner.invoke(
cli,
[
"source",
"delete-by-title",
"Exact Title",
"-n",
"nb_123",
"-y",
"--json",
],
obj=inject_client(client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["action"] == "delete-by-title"
assert payload["source_id"] == "src_999"
assert payload["title"] == "Exact Title"
# ----------------------------------------------------------------------------
# source clean
# ----------------------------------------------------------------------------
class TestSourceCleanCharacterization:
def test_clean_already_clean_text_mode(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[])
result = runner.invoke(cli, ["source", "clean", "-n", "nb_123"], obj=inject_client(client))
assert result.exit_code == 0
assert "already clean" in result.output.lower()
def test_clean_already_clean_json_mode(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[])
result = runner.invoke(
cli, ["source", "clean", "-n", "nb_123", "--json"], obj=inject_client(client)
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["action"] == "clean"
assert payload["status"] == "already_clean"
assert payload["deleted_count"] == 0
# ----------------------------------------------------------------------------
# source fulltext
# ----------------------------------------------------------------------------
class TestSourceFulltextCharacterization:
def test_fulltext_text_mode_prints_content(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.get_fulltext = AsyncMock(
return_value=SourceFulltext(
source_id="src_1",
title="T",
content="hello world",
char_count=11,
url=None,
)
)
result = runner.invoke(
cli, ["source", "fulltext", "src_1", "-n", "nb_123"], obj=inject_client(client)
)
assert result.exit_code == 0
assert "hello world" in result.output
def test_fulltext_json_mode_emits_dataclass_payload(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
client.sources.get_fulltext = AsyncMock(
return_value=SourceFulltext(
source_id="src_1",
title="T",
content="hi",
_type_code=5,
char_count=2,
url=None,
)
)
result = runner.invoke(
cli,
["source", "fulltext", "src_1", "-n", "nb_123", "--json"],
obj=inject_client(client),
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["source_id"] == "src_1"
assert payload["kind"] == "web_page"
assert payload["content"] == "hi"
assert payload["char_count"] == 2
assert "_type_code" not in payload
# ----------------------------------------------------------------------------
# source guide
# ----------------------------------------------------------------------------
class TestSourceGuideCharacterization:
def test_guide_text_mode_prints_summary_and_keywords(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
client.sources.get_guide = AsyncMock(
return_value=source_guide({"summary": "summary text", "keywords": ["a", "b"]})
)
result = runner.invoke(
cli, ["source", "guide", "src_1", "-n", "nb_123"], obj=inject_client(client)
)
assert result.exit_code == 0
assert "summary text" in result.output
assert "Keywords:" in result.output
assert "a, b" in result.output
def test_guide_json_mode_emits_summary_and_keywords(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
client.sources.get_guide = AsyncMock(
return_value=source_guide({"summary": "s", "keywords": ["k1"]})
)
result = runner.invoke(
cli, ["source", "guide", "src_1", "-n", "nb_123", "--json"], obj=inject_client(client)
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["source_id"] == "src_1"
assert payload["summary"] == "s"
assert payload["keywords"] == ["k1"]
# ----------------------------------------------------------------------------
# source add-research
# ----------------------------------------------------------------------------
class TestSourceAddResearchCharacterization:
def test_add_research_no_wait_returns_after_start(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
client.research.start = AsyncMock(return_value=research_start({"task_id": "task_123"}))
result = runner.invoke(
cli,
[
"source",
"add-research",
"machine learning",
"-n",
"nb_123",
"--no-wait",
],
obj=inject_client(client),
)
assert result.exit_code == 0
assert "Task ID:" in result.output
assert "Research started" in result.output
def test_add_research_no_wait_with_import_all_is_usage_error(
self, runner, mock_auth, patched_fetch_tokens
):
client = create_mock_client()
result = runner.invoke(
cli,
[
"source",
"add-research",
"ml",
"-n",
"nb_123",
"--no-wait",
"--import-all",
],
obj=inject_client(client),
)
assert result.exit_code == 2
assert "--import-all requires" in result.output
# ----------------------------------------------------------------------------
# source wait
# ----------------------------------------------------------------------------
class TestSourceWaitCharacterization:
def test_wait_success_text_mode(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.wait_until_ready = AsyncMock(
return_value=Source(id="src_1", title="Ready One")
)
result = runner.invoke(
cli, ["source", "wait", "src_1", "-n", "nb_123"], obj=inject_client(client)
)
assert result.exit_code == 0
assert "Source ready:" in result.output
assert "src_1" in result.output
def test_wait_success_json_mode(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.wait_until_ready = AsyncMock(
return_value=Source(id="src_1", title="Ready", status=2)
)
result = runner.invoke(
cli, ["source", "wait", "src_1", "-n", "nb_123", "--json"], obj=inject_client(client)
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "ready"
assert payload["source_id"] == "src_1"
def test_wait_not_found_exits_1_json_mode(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[Source(id="src_1", title="One")])
client.sources.wait_until_ready = AsyncMock(side_effect=SourceNotFoundError("src_1"))
result = runner.invoke(
cli,
["source", "wait", "src_1", "-n", "nb_123", "--json"],
obj=inject_client(client),
)
assert result.exit_code == 1
payload = json.loads(result.output)
assert payload["status"] == "not_found"
def test_wait_processing_error_exits_1(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[Source(id="src_1", title="One")])
client.sources.wait_until_ready = AsyncMock(
side_effect=SourceProcessingError("src_1", status=4)
)
result = runner.invoke(
cli, ["source", "wait", "src_1", "-n", "nb_123", "--json"], obj=inject_client(client)
)
assert result.exit_code == 1
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["status_code"] == 4
def test_wait_timeout_exits_2(self, runner, mock_auth, patched_fetch_tokens):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[Source(id="src_1", title="One")])
client.sources.wait_until_ready = AsyncMock(
side_effect=SourceTimeoutError("src_1", timeout=5.0, last_status=3)
)
result = runner.invoke(
cli, ["source", "wait", "src_1", "-n", "nb_123", "--json"], obj=inject_client(client)
)
assert result.exit_code == 2
payload = json.loads(result.output)
assert payload["status"] == "timeout"
assert payload["last_status_code"] == 3