Files
teng-lin--notebooklm-py/tests/unit/cli/test_source_content_rendering.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

564 lines
17 KiB
Python

"""CliRunner coverage for source-content command rendering branches."""
from __future__ import annotations
import json
from collections.abc import Iterator
from contextlib import contextmanager
from unittest.mock import AsyncMock, patch
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
from notebooklm.notebooklm_cli import cli
from notebooklm.types import Source, SourceFulltext
from .conftest import create_mock_client, inject_client, source_guide
@contextmanager
def _patched_source_client(client) -> Iterator[dict]:
"""Yield the ``ctx.obj`` payload that injects ``client`` into the command.
Callers thread the yielded value into ``runner.invoke(cli, args, obj=obj)``.
The ``fetch_tokens`` auth seam is still patched here.
"""
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as fetch_tokens:
fetch_tokens.return_value = ("csrf", "session")
yield inject_client(client)
def _client_resolving_source(source_id: str = "src_1", title: str = "Source One"):
client = create_mock_client()
client.sources.list = AsyncMock(return_value=[Source(id=source_id, title=title)])
return client
def _client_with_fulltext(content: str, *, type_code: int | None = None):
client = _client_resolving_source()
payload = {
"source_id": "src_1",
"title": "Source One",
"content": content,
"char_count": len(content),
}
if type_code is not None:
payload["_type_code"] = type_code
client.sources.get_fulltext = AsyncMock(return_value=SourceFulltext(**payload))
return client
@pytest.mark.parametrize("json_output", [False, True])
def test_source_get_not_found_renderer_exits_one(
runner: CliRunner,
mock_auth,
json_output: bool,
) -> None:
source_id = "11111111-2222-3333-4444-555555555555"
client = create_mock_client()
client.sources.get_or_none = AsyncMock(return_value=None)
args = ["source", "get", source_id, "-n", "nb_123"]
if json_output:
args.append("--json")
with _patched_source_client(client) as obj:
result = runner.invoke(cli, args, obj=obj)
assert result.exit_code == 1, result.output
if json_output:
payload = json.loads(result.output)
assert payload["error"] is True
assert payload["code"] == "NOT_FOUND"
assert payload["source_id"] == source_id
else:
assert "Source not found" in result.output
def test_source_fulltext_json_renderer_emits_full_payload(
runner: CliRunner,
mock_auth,
) -> None:
client = _client_resolving_source()
client.sources.get_fulltext = AsyncMock(
return_value=SourceFulltext(
source_id="src_1",
title="Source One",
content="indexed content",
_type_code=5,
char_count=15,
)
)
with _patched_source_client(client) as obj:
result = runner.invoke(
cli, ["source", "fulltext", "src_1", "-n", "nb_123", "--json"], obj=obj
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["source_id"] == "src_1"
assert payload["title"] == "Source One"
assert payload["kind"] == "web_page"
assert payload["content"] == "indexed content"
assert payload["char_count"] == 15
assert "_type_code" not in payload
def test_source_fulltext_json_output_file_writes_content_and_emits_metadata(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source.txt"
content = "full content for disk"
client = _client_resolving_source()
client.sources.get_fulltext = AsyncMock(
return_value=SourceFulltext(
source_id="src_1",
title="Source One",
content=content,
_type_code=5,
char_count=len(content),
)
)
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"--json",
"-o",
str(output_file),
],
obj=obj,
)
assert result.exit_code == 0, result.output
assert output_file.read_text(encoding="utf-8") == content
payload = json.loads(result.output)
assert payload == {
"path": str(output_file),
"bytes": len(content.encode("utf-8")),
"source_id": "src_1",
"title": "Source One",
"kind": "web_page",
}
def test_source_fulltext_output_file_auto_renames_existing_file(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source [draft].txt"
renamed_file = tmp_path / "source [draft] (2).txt"
output_file.write_text("existing content", encoding="utf-8")
content = "replacement content"
client = _client_with_fulltext(content)
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
["source", "fulltext", "src_1", "-n", "nb_123", "-o", str(output_file)],
obj=obj,
)
assert result.exit_code == 0, result.output
assert output_file.read_text(encoding="utf-8") == "existing content"
assert renamed_file.read_text(encoding="utf-8") == content
assert f"Saved {len(content)} chars to" in result.output
assert str(renamed_file) in result.output
def test_source_fulltext_json_output_file_auto_renames_and_emits_actual_path(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source.txt"
renamed_file = tmp_path / "source (2).txt"
output_file.write_text("existing content", encoding="utf-8")
content = "replacement content"
client = _client_with_fulltext(content, type_code=5)
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"--json",
"-o",
str(output_file),
],
obj=obj,
)
assert result.exit_code == 0, result.output
assert output_file.read_text(encoding="utf-8") == "existing content"
assert renamed_file.read_text(encoding="utf-8") == content
payload = json.loads(result.output)
assert payload["path"] == str(renamed_file)
assert payload["bytes"] == len(content.encode("utf-8"))
def test_source_fulltext_output_file_no_clobber_rejects_existing_file(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source.txt"
output_file.write_text("existing content", encoding="utf-8")
client = _client_with_fulltext("replacement content")
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"-o",
str(output_file),
"--no-clobber",
],
obj=obj,
)
assert result.exit_code == 1, result.output
assert output_file.read_text(encoding="utf-8") == "existing content"
client.sources.get_fulltext.assert_not_awaited()
assert f"File exists: {output_file}" in result.output
assert "Use --force to overwrite" in result.output
def test_source_fulltext_json_output_file_no_clobber_rejects_existing_file(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source.txt"
output_file.write_text("existing content", encoding="utf-8")
client = _client_with_fulltext("replacement content")
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"--json",
"-o",
str(output_file),
"--no-clobber",
],
obj=obj,
)
assert result.exit_code == 1, result.output
assert output_file.read_text(encoding="utf-8") == "existing content"
client.sources.get_fulltext.assert_not_awaited()
payload = json.loads(result.output)
assert payload == {
"error": True,
"code": "FILE_EXISTS",
"message": f"File exists: {output_file}",
"path": str(output_file),
"suggestion": "Use --force to overwrite or choose a different path",
}
def test_source_fulltext_output_rejects_directory_before_fetching(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_dir = tmp_path / "fulltext"
output_dir.mkdir()
client = _client_with_fulltext("content that should not be fetched")
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
["source", "fulltext", "src_1", "-n", "nb_123", "-o", str(output_dir)],
obj=obj,
)
assert result.exit_code == 2, result.output
assert f"Output path is a directory: {output_dir}" in result.output
assert not result.output.strip().startswith("{")
client.sources.list.assert_not_awaited()
client.sources.get_fulltext.assert_not_awaited()
def test_source_fulltext_json_output_rejects_directory_before_fetching(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_dir = tmp_path / "fulltext"
output_dir.mkdir()
client = _client_with_fulltext("content that should not be fetched")
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"--json",
"-o",
str(output_dir),
],
obj=obj,
)
assert result.exit_code == 1, result.output
payload = json.loads(result.output)
assert payload == {
"error": True,
"code": "VALIDATION_ERROR",
"message": f"Output path is a directory: {output_dir}",
}
client.sources.list.assert_not_awaited()
client.sources.get_fulltext.assert_not_awaited()
def test_source_fulltext_output_file_force_overwrites_existing_file(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source.txt"
output_file.write_text("existing content", encoding="utf-8")
content = "replacement content"
client = _client_with_fulltext(content)
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"-o",
str(output_file),
"--force",
],
obj=obj,
)
assert result.exit_code == 0, result.output
assert output_file.read_text(encoding="utf-8") == content
assert not (tmp_path / "source (2).txt").exists()
def test_source_fulltext_output_rejects_force_and_no_clobber_json(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source.txt"
client = _client_with_fulltext("replacement content")
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"--json",
"-o",
str(output_file),
"--force",
"--no-clobber",
],
obj=obj,
)
assert result.exit_code == 1, result.output
payload = json.loads(result.output)
assert payload == {
"error": True,
"code": "VALIDATION_ERROR",
"message": "Cannot specify both --force and --no-clobber",
}
def test_source_fulltext_output_rejects_force_and_no_clobber_text(
runner: CliRunner,
mock_auth,
tmp_path,
) -> None:
output_file = tmp_path / "source.txt"
client = _client_with_fulltext("replacement content")
with _patched_source_client(client) as obj:
result = runner.invoke(
cli,
[
"source",
"fulltext",
"src_1",
"-n",
"nb_123",
"-o",
str(output_file),
"--force",
"--no-clobber",
],
obj=obj,
)
assert result.exit_code == 2, result.output
assert "Cannot specify both --force and --no-clobber" in result.output
assert not result.output.strip().startswith("{")
client.sources.get_fulltext.assert_not_awaited()
def test_source_guide_empty_text_renderer_uses_empty_state(
runner: CliRunner,
mock_auth,
) -> None:
client = _client_resolving_source()
client.sources.get_guide = AsyncMock(
return_value=source_guide({"summary": " ", "keywords": ["", " ", 7, None]})
)
with _patched_source_client(client) as obj:
result = runner.invoke(cli, ["source", "guide", "src_1", "-n", "nb_123"], obj=obj)
assert result.exit_code == 0, result.output
assert "No guide available" in result.output
assert "Keywords:" not in result.output
@pytest.mark.parametrize("json_output", [False, True])
def test_source_guide_populated_renderer_strips_keywords(
runner: CliRunner,
mock_auth,
json_output: bool,
) -> None:
client = _client_resolving_source()
client.sources.get_guide = AsyncMock(
return_value=source_guide(
{"summary": "Summary text", "keywords": [" alpha ", "", "beta", " "]}
)
)
args = ["source", "guide", "src_1", "-n", "nb_123"]
if json_output:
args.append("--json")
with _patched_source_client(client) as obj:
result = runner.invoke(cli, args, obj=obj)
assert result.exit_code == 0, result.output
if json_output:
payload = json.loads(result.output)
assert payload["summary"] == "Summary text"
assert payload["keywords"] == ["alpha", "beta"]
else:
assert "Summary text" in result.output
assert "alpha, beta" in result.output
@pytest.mark.parametrize(
("is_fresh", "json_output", "expected_stale"),
[
(False, False, True),
(True, False, False),
(False, True, True),
(True, True, False),
],
)
def test_source_stale_renderer_default_exits_zero_on_success(
runner: CliRunner,
mock_auth,
is_fresh: bool,
json_output: bool,
expected_stale: bool,
) -> None:
"""Default policy: exit 0 on a successful freshness check regardless of verdict.
The verdict is communicated through stdout text (or the JSON
``stale``/``fresh`` fields) — callers branch on that, not the exit
code. See ``docs/cli-exit-codes.md`` and the ``--exit-on-stale``
flag for the back-compat inverted predicate.
"""
client = _client_resolving_source()
client.sources.check_freshness = AsyncMock(return_value=is_fresh)
args = ["source", "stale", "src_1", "-n", "nb_123"]
if json_output:
args.append("--json")
with _patched_source_client(client) as obj:
result = runner.invoke(cli, args, obj=obj)
assert result.exit_code == 0, result.output
if json_output:
payload = json.loads(result.output)
assert payload["stale"] is expected_stale
assert payload["fresh"] is is_fresh
else:
assert ("stale" if expected_stale else "fresh") in result.output.lower()
@pytest.mark.parametrize(
("is_fresh", "json_output", "expected_exit", "expected_stale"),
[
(False, False, 0, True),
(True, False, 1, False),
(False, True, 0, True),
(True, True, 1, False),
],
)
def test_source_stale_renderer_exit_on_stale_flag_inverts_exit_codes(
runner: CliRunner,
mock_auth,
is_fresh: bool,
json_output: bool,
expected_exit: int,
expected_stale: bool,
) -> None:
"""``--exit-on-stale`` opts into back-compat inverted predicate semantics."""
client = _client_resolving_source()
client.sources.check_freshness = AsyncMock(return_value=is_fresh)
args = ["source", "stale", "src_1", "-n", "nb_123", "--exit-on-stale"]
if json_output:
args.append("--json")
with _patched_source_client(client) as obj:
result = runner.invoke(cli, args, obj=obj)
assert result.exit_code == expected_exit, result.output
if json_output:
payload = json.loads(result.output)
assert payload["stale"] is expected_stale
assert payload["fresh"] is is_fresh
else:
assert ("stale" if expected_stale else "fresh") in result.output.lower()