chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user