Files
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

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))