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

1089 lines
43 KiB
Python

"""Tests for resolve_notebook_id and resolve_source_id partial ID matching."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import click
import pytest
from notebooklm.cli.resolve import resolve_notebook_id, resolve_source_id, resolve_source_ids
from notebooklm.types import Notebook, Source
@pytest.fixture
def mock_client():
"""Create a mock client with notebooks.list method."""
client = MagicMock()
client.notebooks = MagicMock()
return client
@pytest.fixture
def sample_notebooks():
"""Sample notebooks for testing."""
return [
Notebook(
id="abc123def456ghi789",
title="First Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
Notebook(
id="xyz789uvw456rst123",
title="Second Notebook",
created_at=datetime(2024, 1, 2),
is_owner=False,
),
Notebook(
id="abc999zzz888yyy777",
title="Third Notebook",
created_at=datetime(2024, 1, 3),
is_owner=True,
),
]
class TestResolveNotebookId:
"""Test partial notebook ID resolution."""
@pytest.mark.asyncio
async def test_exact_match_returns_unchanged(self, mock_client, sample_notebooks):
"""Exact full ID match returns the ID unchanged."""
mock_client.notebooks.list = AsyncMock(return_value=sample_notebooks)
result = await resolve_notebook_id(mock_client, "abc123def456ghi789")
assert result == "abc123def456ghi789"
@pytest.mark.asyncio
async def test_unique_prefix_returns_full_id(self, mock_client, sample_notebooks):
"""Unique prefix returns the full matched ID."""
mock_client.notebooks.list = AsyncMock(return_value=sample_notebooks)
# "xyz" uniquely matches "xyz789uvw456rst123"
mock_console = MagicMock()
result = await resolve_notebook_id(mock_client, "xyz", stdout_console=mock_console)
assert result == "xyz789uvw456rst123"
# Should print a match message
mock_console.print.assert_called()
@pytest.mark.asyncio
async def test_ambiguous_prefix_raises_exception(self, mock_client, sample_notebooks):
"""Ambiguous prefix (matches multiple) raises ClickException."""
mock_client.notebooks.list = AsyncMock(return_value=sample_notebooks)
# "abc" matches both "abc123..." and "abc999..."
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, "abc")
assert "Ambiguous" in str(exc_info.value)
assert "abc123" in str(exc_info.value)
assert "abc999" in str(exc_info.value)
@pytest.mark.asyncio
async def test_exact_match_wins_over_prefix_ambiguity(self, mock_client):
"""Exact short IDs win even when another item shares that prefix."""
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="abc",
title="Exact Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
Notebook(
id="abc123def456ghi789",
title="Prefixed Notebook",
created_at=datetime(2024, 1, 2),
is_owner=True,
),
]
)
mock_console = MagicMock()
result = await resolve_notebook_id(mock_client, "abc", stdout_console=mock_console)
assert result == "abc"
mock_console.print.assert_not_called()
@pytest.mark.asyncio
async def test_no_match_raises_exception(self, mock_client, sample_notebooks):
"""No matching prefix raises ClickException with helpful message."""
mock_client.notebooks.list = AsyncMock(return_value=sample_notebooks)
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, "zzz")
assert "No notebook found" in str(exc_info.value)
assert "notebooklm list" in str(exc_info.value)
@pytest.mark.asyncio
async def test_uuid_shaped_id_returns_without_listing(self, mock_client):
"""36-char UUID-shaped IDs fast-path without hitting the backend."""
# Canonical 8-4-4-4-12 UUID layout - 36 chars, all hex + dashes.
uuid_id = "abc12345-6789-4abc-def0-1234567890ab"
assert len(uuid_id) == 36
mock_client.notebooks.list = AsyncMock()
result = await resolve_notebook_id(mock_client, uuid_id)
assert result == uuid_id
mock_client.notebooks.list.assert_not_called()
@pytest.mark.asyncio
async def test_uuid_shaped_id_mixed_case_returns_without_listing(self, mock_client):
"""Mixed-case 36-char UUID-shaped IDs also fast-path."""
uuid_id = "ABC12345-6789-4ABC-Def0-1234567890aB"
assert len(uuid_id) == 36
mock_client.notebooks.list = AsyncMock()
result = await resolve_notebook_id(mock_client, uuid_id)
assert result == uuid_id
mock_client.notebooks.list.assert_not_called()
@pytest.mark.asyncio
async def test_25_char_prefix_of_uuid_resolves_via_local_matching(self, mock_client):
"""A 25-char prefix of a 36-char UUID resolves locally, not via the backend.
Regression for P1.T9: the previous length-based fast-path (>= 20 chars)
bypassed local matching for any 20-35 char prefix of a UUID, sending the
truncated string straight to the backend. Per the acceptance criteria,
this path must also emit the ``Matched:`` diagnostic so users can see
which full ID the prefix resolved to.
"""
full_uuid = "abc12345-6789-4abc-def0-1234567890ab"
partial_25 = full_uuid[:25] # "abc12345-6789-4abc-def0-1"
assert len(partial_25) == 25
assert len(full_uuid) == 36
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id=full_uuid,
title="UUID Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_console = MagicMock()
result = await resolve_notebook_id(mock_client, partial_25, stdout_console=mock_console)
assert result == full_uuid
# Local matching MUST have happened, i.e. the backend was listed.
mock_client.notebooks.list.assert_awaited_once()
# And the "Matched: ..." diagnostic from the acceptance criteria must fire.
mock_console.print.assert_called_once()
printed = mock_console.print.call_args.args[0]
assert "Matched" in printed
@pytest.mark.asyncio
async def test_36_char_non_hex_string_is_not_fast_pathed(self, mock_client):
"""A 36-char string containing non-hex characters does NOT fast-path.
Only UUID-shaped strings (hex digits + dashes, 36 chars, 8-4-4-4-12 layout)
qualify; a 36-char string with letters outside ``[0-9a-fA-F]`` must go
through the local prefix-matching path so a typo cannot reach the backend
as a malformed ID.
"""
# 36 chars, 8-4-4-4-12 dash layout, but includes 'z' (non-hex). The
# matching notebook in the list confirms local resolution succeeded.
non_hex_36 = "zzz12345-6789-4zzz-zzz0-1234567890ab"
assert len(non_hex_36) == 36
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id=non_hex_36,
title="Non-hex 36-char ID",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
result = await resolve_notebook_id(mock_client, non_hex_36, stdout_console=MagicMock())
assert result == non_hex_36
# Backend listing MUST have happened (no fast-path).
mock_client.notebooks.list.assert_awaited_once()
@pytest.mark.asyncio
async def test_36_char_all_dashes_is_not_fast_pathed(self, mock_client):
"""Degenerate 36-char input (all dashes) does NOT fast-path.
The 8-4-4-4-12 layout requires hex digits in each block, so a pathological
``"-" * 36`` input cannot bypass local resolution - it gets routed through
the local prefix-match path and surfaces a clear "no match" error.
"""
all_dashes = "-" * 36
assert len(all_dashes) == 36
mock_client.notebooks.list = AsyncMock(return_value=[])
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, all_dashes)
assert "No notebook found" in str(exc_info.value)
# Backend listing MUST have happened (no fast-path).
mock_client.notebooks.list.assert_awaited_once()
@pytest.mark.asyncio
async def test_35_char_uuid_shaped_is_not_fast_pathed(self, mock_client):
"""A 35-char string (one short of a UUID) does NOT fast-path.
Boundary check: the regex requires exactly 36 chars in the 8-4-4-4-12
layout. A 35-char input must take the local list-and-match path.
"""
# Drop the last char of a canonical UUID -> 35 chars, still hex+dash but
# with a 11-digit final block instead of 12.
short_uuid = "abc12345-6789-4abc-def0-1234567890a"
assert len(short_uuid) == 35
full_uuid = "abc12345-6789-4abc-def0-1234567890ab"
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id=full_uuid,
title="UUID Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
result = await resolve_notebook_id(mock_client, short_uuid, stdout_console=MagicMock())
assert result == full_uuid
mock_client.notebooks.list.assert_awaited_once()
@pytest.mark.asyncio
async def test_37_char_uuid_shaped_is_not_fast_pathed(self, mock_client):
"""A 37-char string (one over a UUID) does NOT fast-path.
Boundary check on the other side: any extra character past the canonical
36 fails the regex and forces the local path. With no match in the list,
the resolver raises a clear "no match" error.
"""
long_uuid = "abc12345-6789-4abc-def0-1234567890abc"
assert len(long_uuid) == 37
mock_client.notebooks.list = AsyncMock(return_value=[])
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, long_uuid)
assert "No notebook found" in str(exc_info.value)
mock_client.notebooks.list.assert_awaited_once()
@pytest.mark.asyncio
async def test_36_char_wrong_dash_placement_is_not_fast_pathed(self, mock_client):
"""36-char hex+dash with wrong dash placement does NOT fast-path.
The tightened regex enforces the exact 8-4-4-4-12 layout, so a 36-char
string with the right character classes but the wrong layout (e.g. dashes
slipped one position over) must go through local resolution.
"""
# Same 32 hex chars as a canonical UUID, but dashes shifted one position
# (9-3-4-4-12 instead of 8-4-4-4-12). Total length still 36.
wrong_layout = "abc123456-789-4abc-def0-1234567890ab"
assert len(wrong_layout) == 36
mock_client.notebooks.list = AsyncMock(return_value=[])
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, wrong_layout)
assert "No notebook found" in str(exc_info.value)
mock_client.notebooks.list.assert_awaited_once()
@pytest.mark.asyncio
async def test_empty_id_raises_exception(self, mock_client):
"""Empty string raises ClickException."""
mock_client.notebooks.list = AsyncMock()
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, "")
assert "cannot be empty" in str(exc_info.value)
mock_client.notebooks.list.assert_not_called()
@pytest.mark.asyncio
async def test_none_id_raises_exception(self, mock_client):
"""None raises ClickException."""
mock_client.notebooks.list = AsyncMock()
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, None)
assert "cannot be empty" in str(exc_info.value)
mock_client.notebooks.list.assert_not_called()
@pytest.mark.asyncio
async def test_case_insensitive_matching(self, mock_client, sample_notebooks):
"""Prefix matching should be case-insensitive."""
mock_client.notebooks.list = AsyncMock(return_value=sample_notebooks)
# "XYZ" should match "xyz789..." (case-insensitive)
result = await resolve_notebook_id(mock_client, "XYZ", stdout_console=MagicMock())
assert result == "xyz789uvw456rst123"
@pytest.mark.asyncio
async def test_exact_short_id_no_message(self, mock_client, sample_notebooks):
"""Exact match with a non-UUID ID returns without printing a match message."""
mock_client.notebooks.list = AsyncMock(return_value=sample_notebooks)
# Create a notebook with a short ID that we'll match exactly
mock_client.notebooks.list = AsyncMock(
return_value=[
Notebook(
id="shortid",
title="Short ID Notebook",
created_at=datetime(2024, 1, 1),
is_owner=True,
),
]
)
mock_console = MagicMock()
result = await resolve_notebook_id(mock_client, "shortid", stdout_console=mock_console)
assert result == "shortid"
# Should NOT print match message since it's an exact match
mock_console.print.assert_not_called()
class TestResolveNotebookIdAmbiguityDisplay:
"""Test the display format of ambiguous match errors."""
@pytest.mark.asyncio
async def test_shows_up_to_five_matches(self, mock_client):
"""Ambiguous error shows up to 5 matching notebooks."""
notebooks = [
Notebook(
id=f"abc{i}00000000000000",
title=f"Notebook {i}",
created_at=datetime(2024, 1, i + 1),
is_owner=True,
)
for i in range(7)
]
mock_client.notebooks.list = AsyncMock(return_value=notebooks)
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, "abc")
error_msg = str(exc_info.value)
assert "matches 7 notebooks" in error_msg
assert "... and 2 more" in error_msg
@pytest.mark.asyncio
async def test_shows_notebook_titles_in_ambiguous_error(self, mock_client, sample_notebooks):
"""Ambiguous error includes notebook titles."""
mock_client.notebooks.list = AsyncMock(return_value=sample_notebooks)
with pytest.raises(click.ClickException) as exc_info:
await resolve_notebook_id(mock_client, "abc")
error_msg = str(exc_info.value)
assert "First Notebook" in error_msg
assert "Third Notebook" in error_msg
# =============================================================================
# Tests for resolve_source_id
# =============================================================================
@pytest.fixture
def mock_client_with_sources():
"""Create a mock client with sources.list method."""
client = MagicMock()
client.sources = MagicMock()
return client
@pytest.fixture
def sample_sources():
"""Sample sources for testing."""
return [
Source(id="src123def456ghi789", title="First Source"),
Source(id="xyz789uvw456rst123", title="Second Source"),
Source(id="src999zzz888yyy777", title="Third Source"),
]
class TestResolveSourceId:
"""Test partial source ID resolution."""
@pytest.mark.asyncio
async def test_exact_match_returns_unchanged(self, mock_client_with_sources, sample_sources):
"""Exact full ID match returns the ID unchanged."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
result = await resolve_source_id(mock_client_with_sources, "nb_123", "src123def456ghi789")
assert result == "src123def456ghi789"
@pytest.mark.asyncio
async def test_unique_prefix_returns_full_id(self, mock_client_with_sources, sample_sources):
"""Unique prefix returns the full matched ID."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
# "xyz" uniquely matches "xyz789uvw456rst123"
mock_console = MagicMock()
result = await resolve_source_id(
mock_client_with_sources,
"nb_123",
"xyz",
stdout_console=mock_console,
)
assert result == "xyz789uvw456rst123"
# Should print a match message
mock_console.print.assert_called()
@pytest.mark.asyncio
async def test_ambiguous_prefix_raises_exception(
self, mock_client_with_sources, sample_sources
):
"""Ambiguous prefix (matches multiple) raises ClickException."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
# "src" matches both "src123..." and "src999..."
with pytest.raises(click.ClickException) as exc_info:
await resolve_source_id(mock_client_with_sources, "nb_123", "src")
assert "Ambiguous" in str(exc_info.value)
assert "src123" in str(exc_info.value)
assert "src999" in str(exc_info.value)
@pytest.mark.asyncio
async def test_exact_match_wins_over_prefix_ambiguity(self, mock_client_with_sources):
"""Exact short source IDs win even when another source shares that prefix."""
mock_client_with_sources.sources.list = AsyncMock(
return_value=[
Source(id="src", title="Exact Source"),
Source(id="src123def456ghi789", title="Prefixed Source"),
]
)
mock_console = MagicMock()
result = await resolve_source_id(
mock_client_with_sources,
"nb_123",
"src",
stdout_console=mock_console,
)
assert result == "src"
mock_console.print.assert_not_called()
@pytest.mark.asyncio
async def test_no_match_raises_exception(self, mock_client_with_sources, sample_sources):
"""No matching prefix raises ClickException with helpful message."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
with pytest.raises(click.ClickException) as exc_info:
await resolve_source_id(mock_client_with_sources, "nb_123", "zzz")
assert "No source found" in str(exc_info.value)
assert "notebooklm source list" in str(exc_info.value)
@pytest.mark.asyncio
async def test_uuid_shaped_id_returns_without_listing(self, mock_client_with_sources):
"""36-char UUID-shaped IDs fast-path without hitting the backend."""
uuid_id = "abc12345-6789-4abc-def0-1234567890ab"
assert len(uuid_id) == 36
mock_client_with_sources.sources.list = AsyncMock()
result = await resolve_source_id(mock_client_with_sources, "nb_123", uuid_id)
assert result == uuid_id
mock_client_with_sources.sources.list.assert_not_called()
@pytest.mark.asyncio
async def test_25_char_prefix_of_uuid_resolves_via_local_matching(
self, mock_client_with_sources
):
"""A 25-char prefix of a 36-char UUID resolves locally for sources too."""
full_uuid = "abc12345-6789-4abc-def0-1234567890ab"
partial_25 = full_uuid[:25]
assert len(partial_25) == 25
mock_client_with_sources.sources.list = AsyncMock(
return_value=[Source(id=full_uuid, title="UUID Source")]
)
result = await resolve_source_id(
mock_client_with_sources,
"nb_123",
partial_25,
stdout_console=MagicMock(),
)
assert result == full_uuid
mock_client_with_sources.sources.list.assert_awaited_once_with("nb_123")
@pytest.mark.asyncio
async def test_empty_id_raises_exception(self, mock_client_with_sources):
"""Empty string raises ClickException."""
mock_client_with_sources.sources.list = AsyncMock()
with pytest.raises(click.ClickException) as exc_info:
await resolve_source_id(mock_client_with_sources, "nb_123", "")
assert "cannot be empty" in str(exc_info.value)
mock_client_with_sources.sources.list.assert_not_called()
@pytest.mark.asyncio
async def test_none_id_raises_exception(self, mock_client_with_sources):
"""None raises ClickException."""
mock_client_with_sources.sources.list = AsyncMock()
with pytest.raises(click.ClickException) as exc_info:
await resolve_source_id(mock_client_with_sources, "nb_123", None)
assert "cannot be empty" in str(exc_info.value)
mock_client_with_sources.sources.list.assert_not_called()
@pytest.mark.asyncio
async def test_case_insensitive_matching(self, mock_client_with_sources, sample_sources):
"""Prefix matching should be case-insensitive."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
# "XYZ" should match "xyz789..." (case-insensitive)
result = await resolve_source_id(
mock_client_with_sources,
"nb_123",
"XYZ",
stdout_console=MagicMock(),
)
assert result == "xyz789uvw456rst123"
@pytest.mark.asyncio
async def test_passes_notebook_id_to_list(self, mock_client_with_sources, sample_sources):
"""Should pass the notebook ID to sources.list."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
await resolve_source_id(
mock_client_with_sources,
"my_notebook_id",
"xyz",
stdout_console=MagicMock(),
)
mock_client_with_sources.sources.list.assert_called_once_with("my_notebook_id")
class TestResolveSourceIdAmbiguityDisplay:
"""Test the display format of ambiguous match errors."""
@pytest.mark.asyncio
async def test_shows_up_to_five_matches(self, mock_client_with_sources):
"""Ambiguous error shows up to 5 matching sources."""
sources = [Source(id=f"src{i}00000000000000", title=f"Source {i}") for i in range(7)]
mock_client_with_sources.sources.list = AsyncMock(return_value=sources)
with pytest.raises(click.ClickException) as exc_info:
await resolve_source_id(mock_client_with_sources, "nb_123", "src")
error_msg = str(exc_info.value)
assert "matches 7 sources" in error_msg
assert "... and 2 more" in error_msg
@pytest.mark.asyncio
async def test_shows_source_titles_in_ambiguous_error(
self, mock_client_with_sources, sample_sources
):
"""Ambiguous error includes source titles."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
with pytest.raises(click.ClickException) as exc_info:
await resolve_source_id(mock_client_with_sources, "nb_123", "src")
error_msg = str(exc_info.value)
assert "First Source" in error_msg
assert "Third Source" in error_msg
class TestResolveSourceIds:
"""Test multiple source ID resolution."""
@pytest.mark.asyncio
async def test_reuses_source_list_for_multiple_partial_ids(
self, mock_client_with_sources, sample_sources
):
"""Multiple partial IDs share one sources.list call."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
result = await resolve_source_ids(
mock_client_with_sources,
"nb_123",
("xyz", "src999"),
stdout_console=MagicMock(),
)
assert result == ["xyz789uvw456rst123", "src999zzz888yyy777"]
mock_client_with_sources.sources.list.assert_awaited_once_with("nb_123")
@pytest.mark.asyncio
async def test_full_ids_skip_source_list(self, mock_client_with_sources):
"""Full UUID-shaped source IDs pass through without a source list call."""
mock_client_with_sources.sources.list = AsyncMock()
uuid_a = "abc12345-6789-4abc-def0-1234567890ab"
uuid_b = "fedcba98-7654-4321-0fed-cba987654321"
result = await resolve_source_ids(
mock_client_with_sources,
"nb_123",
(uuid_a, uuid_b),
)
assert result == [uuid_a, uuid_b]
mock_client_with_sources.sources.list.assert_not_called()
@pytest.mark.asyncio
async def test_mixed_full_and_partial_ids_list_once(
self, mock_client_with_sources, sample_sources
):
"""Full and partial IDs share one source list call."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
result = await resolve_source_ids(
mock_client_with_sources,
"nb_123",
("src123def456ghi789", "xyz"),
stdout_console=MagicMock(),
)
assert result == ["src123def456ghi789", "xyz789uvw456rst123"]
mock_client_with_sources.sources.list.assert_awaited_once_with("nb_123")
@pytest.mark.asyncio
async def test_duplicate_partial_ids_resolve_once_preserving_duplicates(
self, mock_client_with_sources, sample_sources
):
"""Duplicate partial IDs produce one status message but preserve output shape."""
mock_client_with_sources.sources.list = AsyncMock(return_value=sample_sources)
mock_console = MagicMock()
result = await resolve_source_ids(
mock_client_with_sources,
"nb_123",
("xyz", "xyz"),
stdout_console=mock_console,
)
assert result == ["xyz789uvw456rst123", "xyz789uvw456rst123"]
mock_client_with_sources.sources.list.assert_awaited_once_with("nb_123")
mock_console.print.assert_called_once()
@pytest.mark.asyncio
async def test_empty_source_id_raises_before_listing(self, mock_client_with_sources):
"""Invalid multi-source input does not trigger a source-list RPC."""
mock_client_with_sources.sources.list = AsyncMock()
with pytest.raises(click.ClickException) as exc_info:
await resolve_source_ids(mock_client_with_sources, "nb_123", ("xyz", ""))
assert "cannot be empty" in str(exc_info.value)
mock_client_with_sources.sources.list.assert_not_called()
# ----------------------------------------------------------------------------
# Sync resolver core + entity-specific config (P2.T1 acceptance criteria)
# ----------------------------------------------------------------------------
class TestRequireNotebookEnvVarFallback:
"""Pin the env-var rung of ``require_notebook``'s precedence ladder.
The full ladder is: ``-n`` flag > ``NOTEBOOKLM_NOTEBOOK`` env >
persisted-context > error. ``test_helpers.py`` covers the same paths
via the helpers facade; this class covers them via the canonical
``cli.resolve.require_notebook`` directly so any drift between the two
surfaces is caught here.
"""
def test_argument_wins_over_env_and_context(self, tmp_path, monkeypatch):
from notebooklm.cli.resolve import require_notebook
ctx_file = tmp_path / "context.json"
ctx_file.write_text('{"notebook_id": "nb_from_context"}')
monkeypatch.setenv("NOTEBOOKLM_NOTEBOOK", "nb_from_env")
assert (
require_notebook("nb_from_arg", context_path_fn=lambda **_: ctx_file) == "nb_from_arg"
)
def test_env_var_fills_in_when_no_arg(self, tmp_path, monkeypatch):
"""When ``-n`` is None and there is no context file, env wins."""
from notebooklm.cli.resolve import require_notebook
monkeypatch.setenv("NOTEBOOKLM_NOTEBOOK", "nb_from_env")
assert (
require_notebook(
None,
context_path_fn=lambda **_: tmp_path / "nonexistent.json",
)
== "nb_from_env"
)
def test_env_var_wins_over_context_file(self, tmp_path, monkeypatch):
"""Env-var takes precedence over the persisted active-notebook."""
from notebooklm.cli.resolve import require_notebook
ctx_file = tmp_path / "context.json"
ctx_file.write_text('{"notebook_id": "nb_from_context"}')
monkeypatch.setenv("NOTEBOOKLM_NOTEBOOK", "nb_from_env")
assert require_notebook(None, context_path_fn=lambda **_: ctx_file) == "nb_from_env"
def test_context_file_used_when_no_arg_and_no_env(self, tmp_path, monkeypatch):
"""No arg + no env-var -> fall through to the active-context file."""
from notebooklm.cli.resolve import require_notebook
monkeypatch.delenv("NOTEBOOKLM_NOTEBOOK", raising=False)
ctx_file = tmp_path / "context.json"
ctx_file.write_text('{"notebook_id": "nb_from_context"}')
assert require_notebook(None, context_path_fn=lambda **_: ctx_file) == "nb_from_context"
def test_blank_env_var_falls_through_to_context(self, tmp_path, monkeypatch):
"""Whitespace-only ``NOTEBOOKLM_NOTEBOOK`` is treated as unset."""
from notebooklm.cli.resolve import require_notebook
monkeypatch.setenv("NOTEBOOKLM_NOTEBOOK", " ")
ctx_file = tmp_path / "context.json"
ctx_file.write_text('{"notebook_id": "nb_from_context"}')
assert require_notebook(None, context_path_fn=lambda **_: ctx_file) == "nb_from_context"
def test_env_var_is_stripped(self, tmp_path, monkeypatch):
"""``NOTEBOOKLM_NOTEBOOK`` value is trimmed before being returned."""
from notebooklm.cli.resolve import require_notebook
monkeypatch.setenv("NOTEBOOKLM_NOTEBOOK", " nb_padded ")
assert (
require_notebook(
None,
context_path_fn=lambda **_: tmp_path / "nonexistent.json",
)
== "nb_padded"
)
def test_no_source_anywhere_raises_with_discoverability_hint(self, tmp_path, monkeypatch):
"""All three resolution paths must be named in the error message."""
from notebooklm.cli.resolve import require_notebook
monkeypatch.delenv("NOTEBOOKLM_NOTEBOOK", raising=False)
mock_console = MagicMock()
with pytest.raises(SystemExit):
require_notebook(
None,
context_path_fn=lambda **_: tmp_path / "nonexistent.json",
output_console=mock_console,
)
mock_console.print.assert_called_once()
printed = mock_console.print.call_args[0][0]
assert "-n/--notebook" in printed
assert "NOTEBOOKLM_NOTEBOOK" in printed
assert "notebooklm use" in printed
class TestResolvePartialIdInItems:
"""Direct tests for the sync resolver core that powers both the async
``_resolve_partial_id`` and the download-pre-fetched-list path.
These cover the consolidation contract: identical matching rules
regardless of accessor shape and error factory.
"""
def test_full_uuid_fast_path(self):
from notebooklm.cli.resolve import resolve_partial_id_in_items
full = "abc12345-6789-4abc-def0-1234567890ab"
# An empty items list MUST not be visited at all; the fast-path
# returns before any iteration.
result = resolve_partial_id_in_items(
full,
[],
entity_name="artifact",
list_command="artifact list",
)
assert result == full
def test_full_uuid_no_passthrough_when_disabled(self):
"""``allow_full_id_passthrough=False`` forces membership validation.
Callers that already hold the authoritative item list (download
helpers) opt out of the fast-path so a valid-shape UUID that isn't
in the list surfaces the canonical "not found" error instead of
being passed through and yielding a silent backend 404.
"""
from notebooklm.cli.resolve import resolve_partial_id_in_items
full = "abc12345-6789-4abc-def0-1234567890ab"
with pytest.raises(click.ClickException) as exc_info:
resolve_partial_id_in_items(
full,
[],
entity_name="artifact",
list_command="artifact list",
allow_full_id_passthrough=False,
)
assert "No artifact found starting with" in str(exc_info.value.message)
def test_partial_unique_match_with_attr_accessor(self):
from notebooklm.cli.resolve import resolve_partial_id_in_items
class Item:
def __init__(self, id_, title):
self.id = id_
self.title = title
items = [Item("aaa111", "A"), Item("bbb222", "B")]
assert (
resolve_partial_id_in_items(
"aaa",
items,
entity_name="thing",
list_command="thing list",
stdout_console=MagicMock(),
)
== "aaa111"
)
def test_partial_unique_match_with_dict_accessor(self):
"""Entity-specific config: dict-shaped items via ``id_of``/``title_of``.
This is the canonical consolidation test - the download path uses
:class:`ArtifactDict` and reaches the same matching logic without
first reshaping its inputs.
"""
from notebooklm.cli.resolve import resolve_partial_id_in_items
items = [
{"id": "art_aaa", "title": "First"},
{"id": "art_bbb", "title": "Second"},
]
result = resolve_partial_id_in_items(
"art_a",
items,
entity_name="artifact",
list_command="artifact list",
id_of=lambda a: a["id"],
title_of=lambda a: a["title"],
error_factory=ValueError,
stdout_console=MagicMock(),
)
assert result == "art_aaa"
def test_ambiguous_partial_match_raises_via_error_factory(self):
from notebooklm.cli.resolve import resolve_partial_id_in_items
items = [
{"id": "art_aaa", "title": "First"},
{"id": "art_aab", "title": "Second"},
]
# When error_factory=ValueError, the consolidated resolver raises
# ValueError (not click.ClickException) so the download path's
# ``except ValueError`` continues to work.
with pytest.raises(ValueError) as exc:
resolve_partial_id_in_items(
"art_a",
items,
entity_name="artifact",
list_command="artifact list",
id_of=lambda a: a["id"],
title_of=lambda a: a["title"],
error_factory=ValueError,
)
# The default canonical wording is "Ambiguous ID 'X' matches N artifacts:".
# The download_helpers wrapper translates this to historical wording -
# but at THIS layer the canonical message is what surfaces.
assert "Ambiguous" in str(exc.value)
assert "art_a" in str(exc.value)
def test_no_match_raises_via_error_factory(self):
from notebooklm.cli.resolve import resolve_partial_id_in_items
items = [{"id": "art_xyz", "title": "Only"}]
with pytest.raises(ValueError) as exc:
resolve_partial_id_in_items(
"art_a",
items,
entity_name="artifact",
list_command="artifact list",
id_of=lambda a: a["id"],
title_of=lambda a: a["title"],
error_factory=ValueError,
)
# Canonical wording at this layer ("No artifact found starting with..."
# + the discoverability hint pointing at ``artifact list``).
assert "No artifact found starting with" in str(exc.value)
assert "artifact list" in str(exc.value)
def test_empty_partial_id_raises_via_error_factory(self):
from notebooklm.cli.resolve import resolve_partial_id_in_items
with pytest.raises(ValueError, match="cannot be empty"):
resolve_partial_id_in_items(
" ",
[{"id": "x", "title": "y"}],
entity_name="artifact",
list_command="artifact list",
id_of=lambda a: a["id"],
title_of=lambda a: a["title"],
error_factory=ValueError,
)
def test_default_error_factory_is_click_exception(self):
"""Without an explicit ``error_factory``, the async-path default
``click.ClickException`` is used. This is the contract that the
async ``_resolve_partial_id`` relies on so Click can render its
familiar exit-1 + stderr error."""
from notebooklm.cli.resolve import resolve_partial_id_in_items
with pytest.raises(click.ClickException):
resolve_partial_id_in_items(
"missing",
[],
entity_name="notebook",
list_command="list",
)
def test_exact_match_wins_over_ambiguous_prefix(self):
"""The exact-id-wins rule must hold for the consolidated core too."""
from notebooklm.cli.resolve import resolve_partial_id_in_items
items = [
{"id": "abc", "title": "Exact"},
{"id": "abc12345-6789-4abc-def0-1234567890ab", "title": "Long"},
]
# 'abc' is an exact match for the first item AND a prefix of the
# second. The exact match must win - no "Matched: ..." status, no
# ambiguity error.
mock_console = MagicMock()
result = resolve_partial_id_in_items(
"abc",
items,
entity_name="artifact",
list_command="artifact list",
id_of=lambda a: a["id"],
title_of=lambda a: a["title"],
error_factory=ValueError,
stdout_console=mock_console,
)
assert result == "abc"
mock_console.print.assert_not_called()
def test_matched_status_routes_to_stderr_in_json_mode(self):
"""JSON-mode keeps stdout parseable by routing "Matched..." to stderr."""
from notebooklm.cli.resolve import resolve_partial_id_in_items
items = [{"id": "abc12345", "title": "First"}]
stdout_console = MagicMock()
stderr_console = MagicMock()
result = resolve_partial_id_in_items(
"abc",
items,
entity_name="artifact",
list_command="artifact list",
id_of=lambda a: a["id"],
title_of=lambda a: a["title"],
error_factory=ValueError,
json_output=True,
stdout_console=stdout_console,
stderr_output_console=stderr_console,
)
assert result == "abc12345"
stdout_console.print.assert_not_called()
stderr_console.print.assert_called_once()
printed = stderr_console.print.call_args[0][0]
assert "Matched" in printed
class TestEntitySpecificPartialArtifactId:
"""Cover the download-helpers entity-specific consolidated path.
Acceptance criterion: "entity-specific partial-ID resolution for
artifacts". The download path delegates to ``resolve_partial_id_in_items``
via :func:`notebooklm.cli.download_helpers.resolve_partial_artifact_id`
with the dict-shape accessors and ``ValueError`` factory.
"""
def test_consolidated_path_handles_artifact_dict_shape(self):
from notebooklm.cli.download_helpers import resolve_partial_artifact_id
artifacts = [
{"id": "abc111-aaaa-4abc-def0-000000000001", "title": "First", "created_at": 1},
{"id": "xyz222-aaaa-4abc-def0-000000000002", "title": "Second", "created_at": 2},
]
# Unique prefix resolves through the consolidated core.
assert resolve_partial_artifact_id(artifacts, "abc") == "abc111-aaaa-4abc-def0-000000000001"
def test_consolidated_path_preserves_historical_not_found_wording(self):
"""The download user-visible error retains its historical
wording ("Artifact 'X' not found") even though the consolidated
core uses different wording at the resolve.py layer.
This is the exact contract from
``tests/unit/test_download_helpers.py::test_no_match_raises`` and
downstream user-visible CLI error envelopes.
"""
from notebooklm.cli.download_helpers import resolve_partial_artifact_id
artifacts = [{"id": "xyz222-aaaa-4abc-def0-000000000002", "title": "Only", "created_at": 1}]
with pytest.raises(ValueError) as exc:
resolve_partial_artifact_id(artifacts, "abc")
assert "Artifact 'abc' not found" in str(exc.value)
def test_consolidated_path_preserves_historical_ambiguous_wording(self):
"""Ambiguous-match error retains the "Ambiguous partial ID 'X' matches: ..."
wording downstream callers rely on."""
from notebooklm.cli.download_helpers import resolve_partial_artifact_id
artifacts = [
{"id": "abc111", "title": "Meeting Notes", "created_at": 1},
{"id": "abc222", "title": "Debate Session", "created_at": 2},
]
with pytest.raises(ValueError) as exc:
resolve_partial_artifact_id(artifacts, "abc")
msg = str(exc.value)
assert "Ambiguous partial ID 'abc'" in msg
assert "Meeting Notes" in msg
assert "Debate Session" in msg
def test_consolidated_path_uses_valueerror_not_clickexception(self):
"""The download path's downstream ``except ValueError`` contract is
preserved by the ``error_factory=ValueError`` config."""
import click as _click
from notebooklm.cli.download_helpers import resolve_partial_artifact_id
with pytest.raises(ValueError) as exc:
resolve_partial_artifact_id([], "abc")
assert not isinstance(exc.value, _click.ClickException)
def test_full_uuid_not_in_list_preserves_not_found_contract(self):
"""``resolve_partial_artifact_id`` keeps ``allow_full_id_passthrough=False``.
A canonical-shape UUID absent from ``artifacts`` MUST raise the
historical "Artifact '<id>' not found" wording rather than passing
through and silently 404-ing at the backend. Pins the wrapper-level
integration contract that the core-level
:class:`TestResolvePartialIdInItems` test exercises in isolation.
"""
from notebooklm.cli.download_helpers import resolve_partial_artifact_id
full_uuid = "abc12345-6789-4abc-def0-1234567890ab"
artifacts = [
{"id": "xyz22222-aaaa-4abc-def0-000000000002", "title": "Only", "created_at": 1}
]
with pytest.raises(ValueError) as exc:
resolve_partial_artifact_id(artifacts, full_uuid)
assert str(exc.value) == f"Artifact '{full_uuid}' not found"