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
1089 lines
43 KiB
Python
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"
|