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
453 lines
15 KiB
Python
453 lines
15 KiB
Python
"""Tests for the ``label`` CLI command group.
|
|
|
|
Drives the thin Click handlers in ``cli/label_cmd.py`` via ``CliRunner``,
|
|
injecting a mock client through ``ctx.obj`` (``inject_client``), which seeds
|
|
``ctx.obj["client_factory"]`` so the command builds the mock instead of a real
|
|
``NotebookLMClient``. Covers ``list`` (with the member-id + title join in
|
|
``--json``), ``sources`` (delegates to ``client.labels.sources()``), the CRUD
|
|
verbs (``create`` / ``rename`` / ``emoji`` / ``add`` / ``remove`` / ``delete``),
|
|
and ``generate`` (the ``--yes/-y`` gate on ``--scope all``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from notebooklm.notebooklm_cli import cli
|
|
from notebooklm.types import Label, Source
|
|
|
|
from .conftest import create_mock_client, inject_client
|
|
|
|
|
|
def _client_with_labels(*, labels=None, sources=None):
|
|
client = create_mock_client()
|
|
client.labels = AsyncMock()
|
|
# ``notebooks.list`` already matches ``nb_*`` via the conftest stub; pin the
|
|
# label/source lists per test.
|
|
client.labels.list = AsyncMock(return_value=labels or [])
|
|
client.sources.list = AsyncMock(return_value=sources or [])
|
|
return client
|
|
|
|
|
|
def _run(runner, mock_auth, mock_fetch_tokens, argv, client):
|
|
return runner.invoke(cli, argv, obj=inject_client(client), catch_exceptions=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_list_json_envelope_with_members_and_titles(
|
|
runner, mock_auth, mock_fetch_tokens
|
|
) -> None:
|
|
labels = [
|
|
Label(id="lblaaa111", name="Papers", emoji="📄", source_ids=["s1", "s2"]),
|
|
]
|
|
sources = [Source(id="s1", title="First"), Source(id="s2", title="Second")]
|
|
client = _client_with_labels(labels=labels, sources=sources)
|
|
|
|
result = _run(
|
|
runner, mock_auth, mock_fetch_tokens, ["label", "list", "-n", "nb_123", "--json"], client
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.stdout)
|
|
assert payload["count"] == 1
|
|
label = payload["labels"][0]
|
|
assert label["id"] == "lblaaa111"
|
|
assert label["source_ids"] == ["s1", "s2"]
|
|
assert label["sources"] == [
|
|
{"id": "s1", "title": "First"},
|
|
{"id": "s2", "title": "Second"},
|
|
]
|
|
|
|
|
|
def test_label_list_human_mode(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers", emoji="📄", source_ids=["s1"])]
|
|
sources = [Source(id="s1", title="First")]
|
|
client = _client_with_labels(labels=labels, sources=sources)
|
|
|
|
result = _run(runner, mock_auth, mock_fetch_tokens, ["label", "list", "-n", "nb_123"], client)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Papers" in result.output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label sources
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_sources_delegates_to_api(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers", source_ids=["s1"])]
|
|
client = _client_with_labels(labels=labels)
|
|
client.labels.sources = AsyncMock(return_value=[Source(id="s1", title="First")])
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "sources", "lblaaa111", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
client.labels.sources.assert_awaited_once_with("nb_123", "lblaaa111")
|
|
payload = json.loads(result.stdout)
|
|
assert payload["count"] == 1
|
|
assert payload["sources"][0]["id"] == "s1"
|
|
|
|
|
|
def test_label_sources_resolves_by_name(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers", source_ids=["s1"])]
|
|
client = _client_with_labels(labels=labels)
|
|
client.labels.sources = AsyncMock(return_value=[Source(id="s1", title="First")])
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "sources", "Papers", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# The name "Papers" resolves to lblaaa111 before delegating.
|
|
client.labels.sources.assert_awaited_once_with("nb_123", "lblaaa111")
|
|
|
|
|
|
def test_label_sources_near_miss_text_mode_shows_did_you_mean(
|
|
runner, mock_auth, mock_fetch_tokens
|
|
) -> None:
|
|
"""A label name mistyped with a hyphen for an em-dash gets a text-mode hint (#1787)."""
|
|
labels = [Label(id="lblaaa111", name="Q3 — Papers", source_ids=["s1"])]
|
|
client = _client_with_labels(labels=labels)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "sources", "Q3 - Papers", "-n", "nb_123"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "Did you mean:" in result.output
|
|
assert "Q3 — Papers" in result.output
|
|
assert "lblaaa111" in result.output
|
|
|
|
|
|
def test_label_sources_near_miss_json_mode_carries_candidates(
|
|
runner, mock_auth, mock_fetch_tokens
|
|
) -> None:
|
|
labels = [Label(id="lblaaa111", name="Q3 — Papers", source_ids=["s1"])]
|
|
client = _client_with_labels(labels=labels)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "sources", "Q3 - Papers", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
payload = json.loads(result.stdout)
|
|
assert payload["code"] == "NOT_FOUND"
|
|
assert payload["candidates"] == [{"id": "lblaaa111", "title": "Q3 — Papers"}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_create_json(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
client = _client_with_labels()
|
|
client.labels.create = AsyncMock(return_value=Label(id="lblnew999", name="Topics", emoji="🧠"))
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "create", "Topics", "-n", "nb_123", "--emoji", "🧠", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
client.labels.create.assert_awaited_once_with("nb_123", "Topics", "🧠")
|
|
payload = json.loads(result.stdout)
|
|
assert payload["id"] == "lblnew999"
|
|
assert payload["name"] == "Topics"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label rename / emoji
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_rename_json(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers", emoji="📄")]
|
|
client = _client_with_labels(labels=labels)
|
|
client.labels.rename = AsyncMock(
|
|
return_value=Label(id="lblaaa111", name="Articles", emoji="📄")
|
|
)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "rename", "lblaaa111", "Articles", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
client.labels.rename.assert_awaited_once_with("nb_123", "lblaaa111", "Articles")
|
|
payload = json.loads(result.stdout)
|
|
assert payload["name"] == "Articles"
|
|
|
|
|
|
def test_label_emoji_json(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers", emoji="📄")]
|
|
client = _client_with_labels(labels=labels)
|
|
client.labels.set_emoji = AsyncMock(
|
|
return_value=Label(id="lblaaa111", name="Papers", emoji="🔬")
|
|
)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "emoji", "lblaaa111", "🔬", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
client.labels.set_emoji.assert_awaited_once_with("nb_123", "lblaaa111", "🔬")
|
|
payload = json.loads(result.stdout)
|
|
assert payload["emoji"] == "🔬"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label add (resolve_source_ids)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_add_resolves_source_ids(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers", source_ids=[])]
|
|
client = _client_with_labels(labels=labels, sources=[Source(id="src_1", title="Source One")])
|
|
client.labels.add_sources = AsyncMock(
|
|
return_value=Label(id="lblaaa111", name="Papers", source_ids=["src_1"])
|
|
)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "add", "lblaaa111", "src_1", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# resolve_source_ids expands src_1 (conftest source list has src_1).
|
|
client.labels.add_sources.assert_awaited_once_with("nb_123", "lblaaa111", ["src_1"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label remove (inverse of add; NO --yes gate — un-assign is non-destructive)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_remove_resolves_source_ids(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers", source_ids=["src_1"])]
|
|
client = _client_with_labels(labels=labels, sources=[Source(id="src_1", title="Source One")])
|
|
client.labels.remove_sources = AsyncMock(
|
|
return_value=Label(id="lblaaa111", name="Papers", source_ids=[])
|
|
)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "remove", "lblaaa111", "src_1", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# No confirmation prompt — remove runs straight through.
|
|
client.labels.remove_sources.assert_awaited_once_with("nb_123", "lblaaa111", ["src_1"])
|
|
payload = json.loads(result.stdout)
|
|
assert payload["id"] == "lblaaa111"
|
|
assert payload["removed_source_ids"] == ["src_1"]
|
|
|
|
|
|
def test_label_remove_not_found_routes_through_envelope(
|
|
runner, mock_auth, mock_fetch_tokens
|
|
) -> None:
|
|
from notebooklm.exceptions import LabelNotFoundError
|
|
|
|
labels = [Label(id="lblaaa111", name="Papers", source_ids=["src_1"])]
|
|
client = _client_with_labels(labels=labels, sources=[Source(id="src_1", title="Source One")])
|
|
client.labels.remove_sources = AsyncMock(
|
|
side_effect=LabelNotFoundError("lblaaa111", method_id="le8sX")
|
|
)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "remove", "lblaaa111", "src_1", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "NOT_FOUND"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label delete (--yes gate)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_delete_json_requires_yes(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers")]
|
|
client = _client_with_labels(labels=labels)
|
|
client.labels.delete = AsyncMock(return_value=None)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "delete", "lblaaa111", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
client.labels.delete.assert_not_called()
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
|
|
|
|
def test_label_delete_json_with_yes(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
labels = [Label(id="lblaaa111", name="Papers")]
|
|
client = _client_with_labels(labels=labels)
|
|
client.labels.delete = AsyncMock(return_value=None)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "delete", "lblaaa111", "-n", "nb_123", "--yes", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
client.labels.delete.assert_awaited_once()
|
|
payload = json.loads(result.stdout)
|
|
assert payload["deleted"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label generate (--scope all --yes gate)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_generate_default_scope_unlabeled(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
client = _client_with_labels()
|
|
client.labels.generate = AsyncMock(return_value=[Label(id="lblaaa111", name="Auto")])
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "generate", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
client.labels.generate.assert_awaited_once_with("nb_123", scope="unlabeled")
|
|
payload = json.loads(result.stdout)
|
|
assert payload["count"] == 1
|
|
|
|
|
|
def test_label_generate_scope_all_requires_yes_json(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
client = _client_with_labels()
|
|
client.labels.generate = AsyncMock(return_value=[])
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "generate", "-n", "nb_123", "--scope", "all", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
client.labels.generate.assert_not_called()
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
|
|
|
|
def test_label_generate_scope_all_with_yes(runner, mock_auth, mock_fetch_tokens) -> None:
|
|
client = _client_with_labels()
|
|
client.labels.generate = AsyncMock(return_value=[Label(id="lblaaa111", name="Auto")])
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "generate", "-n", "nb_123", "--scope", "all", "--yes", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
client.labels.generate.assert_awaited_once_with("nb_123", scope="all")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# error routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_label_sources_not_found_routes_through_envelope(
|
|
runner, mock_auth, mock_fetch_tokens
|
|
) -> None:
|
|
from notebooklm.exceptions import LabelNotFoundError
|
|
|
|
labels = [Label(id="lblaaa111", name="Papers")]
|
|
client = _client_with_labels(labels=labels)
|
|
client.labels.sources = AsyncMock(
|
|
side_effect=LabelNotFoundError("lblaaa111", method_id="I3xc3c")
|
|
)
|
|
|
|
result = _run(
|
|
runner,
|
|
mock_auth,
|
|
mock_fetch_tokens,
|
|
["label", "sources", "lblaaa111", "-n", "nb_123", "--json"],
|
|
client,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "NOT_FOUND"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"subcmd",
|
|
["list", "sources", "generate", "create", "rename", "emoji", "add", "remove", "delete"],
|
|
)
|
|
def test_label_subcommands_exist(subcmd) -> None:
|
|
import click
|
|
|
|
label_group = cli.get_command(click.Context(cli), "label")
|
|
assert label_group is not None
|
|
assert subcmd in label_group.list_commands(click.Context(label_group))
|