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

600 lines
26 KiB
Python

"""Unit tests for the MCP name/partial-id resolver.
``mcp/_resolve.py`` adds case-insensitive exact-TITLE matching on top of the
neutral ``_app.resolve.resolve_ref`` (full/partial-UUID + exact-id +
ambiguity). Routing is by token shape (``^[0-9a-fA-F-]+$``): hex-ish tokens take
the id/prefix path, everything else takes the title path. A full canonical UUID
is returned without any list call.
"""
from __future__ import annotations
from dataclasses import dataclass
from unittest.mock import AsyncMock
import pytest
# Skip cleanly when the `mcp` extra (fastmcp) is absent; see conftest.py. The
# resolver itself imports no fastmcp, but the guard keeps this module consistent
# with the rest of the mcp suite and self-protecting if collected directly.
pytest.importorskip("fastmcp")
from notebooklm._app.resolve import AmbiguousIdError # noqa: E402 - after importorskip guard
from notebooklm.exceptions import ( # noqa: E402 - after importorskip guard
ArtifactNotFoundError,
NotebookNotFoundError,
NoteNotFoundError,
SourceNotFoundError,
ValidationError,
)
from notebooklm.mcp._resolve import ( # noqa: E402 - after importorskip guard
resolve_artifact,
resolve_note,
resolve_notebook,
resolve_source,
resolve_sources,
)
FULL_A = "abc12345-6789-4abc-def0-1234567890ab"
FULL_B = "abc12345-6789-4abc-def0-ffffffffffff"
@dataclass
class _NB:
id: str
title: str
@dataclass
class _Src:
id: str
title: str | None
@dataclass
class _Art:
id: str
title: str | None
@dataclass
class _Note:
id: str
title: str | None
def _client(
notebooks: list[_NB] | None = None,
sources: list[_Src] | None = None,
artifacts: list[_Art] | None = None,
notes: list[_Note] | None = None,
) -> AsyncMock:
client = AsyncMock()
client.notebooks.list = AsyncMock(return_value=notebooks or [])
client.sources.list = AsyncMock(return_value=sources or [])
client.artifacts.list = AsyncMock(return_value=artifacts or [])
client.notes.list = AsyncMock(return_value=notes or [])
return client
# --------------------------------------------------------------------------- #
# resolve_notebook
# --------------------------------------------------------------------------- #
async def test_full_uuid_skips_the_list_call() -> None:
client = _client(notebooks=[_NB(FULL_A, "Alpha")])
assert await resolve_notebook(client, FULL_A) == FULL_A
client.notebooks.list.assert_not_called()
async def test_exact_id_match() -> None:
client = _client(notebooks=[_NB("deadbeef", "Alpha"), _NB("cafef00d", "Beta")])
assert await resolve_notebook(client, "deadbeef") == "deadbeef"
client.notebooks.list.assert_awaited_once()
async def test_unique_prefix_match() -> None:
client = _client(notebooks=[_NB("deadbeef0001", "Alpha"), _NB("cafef00d", "Beta")])
assert await resolve_notebook(client, "dead") == "deadbeef0001"
async def test_title_match_case_insensitive() -> None:
client = _client(notebooks=[_NB("deadbeef", "My Notebook"), _NB("cafef00d", "Other")])
assert await resolve_notebook(client, "my notebook") == "deadbeef"
async def test_title_match_casefold_non_ascii() -> None:
"""casefold (not lower) — 'STRASSE' must match the title 'Straße' (ß -> ss)."""
client = _client(notebooks=[_NB("deadbeef", "Straße"), _NB("cafef00d", "Other")])
assert await resolve_notebook(client, "STRASSE") == "deadbeef"
async def test_ambiguous_prefix_raises_with_candidates() -> None:
client = _client(notebooks=[_NB("deadbeef01", "A"), _NB("deadbeef02", "B")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_notebook(client, "deadbeef")
assert set(caught.value.candidate_ids) == {"deadbeef01", "deadbeef02"}
async def test_ambiguous_title_raises_with_candidates() -> None:
client = _client(notebooks=[_NB("deadbeef", "Dup"), _NB("cafef00d", "dup")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_notebook(client, "Dup")
assert set(caught.value.candidate_ids) == {"deadbeef", "cafef00d"}
async def test_no_match_title_raises_not_found() -> None:
client = _client(notebooks=[_NB("deadbeef", "Alpha")])
with pytest.raises(NotebookNotFoundError):
await resolve_notebook(client, "Nonexistent Title")
async def test_no_match_prefix_raises_not_found() -> None:
client = _client(notebooks=[_NB("deadbeef", "Alpha")])
with pytest.raises(NotebookNotFoundError):
await resolve_notebook(client, "ffff")
# --------------------------------------------------------------------------- #
# near-miss candidates on a failed name lookup (issue #1787)
# --------------------------------------------------------------------------- #
async def test_prefix_near_miss_attaches_candidate() -> None:
"""A near-prefix that can't cleanly resolve (a punctuation slip) surfaces the title.
A clean unique title prefix now *resolves* (issue #1786), so it never reaches
the near-miss path. This exercises the surviving near-miss prefix case: a token
that would be a prefix but for an em-dash/hyphen slip, so it misses and the real
title is offered as a candidate.
"""
real = "Scientific — PDF Parsing, Benchmarks & Multimodal Extraction"
client = _client(notebooks=[_NB("37fe5c1d", real), _NB("cafef00d", "Unrelated")])
with pytest.raises(NotebookNotFoundError) as caught:
# hyphen typed for the em-dash -> not a raw prefix -> true miss -> near-miss
await resolve_notebook(client, "Scientific - PDF")
assert [c["id"] for c in caught.value.candidates] == ["37fe5c1d"]
async def test_em_dash_hyphen_near_miss_attaches_candidate() -> None:
"""A hyphen typed for an em-dash still surfaces the real title."""
client = _client(notebooks=[_NB("deadbeef", "Acme — Competitive Intel")])
with pytest.raises(NotebookNotFoundError) as caught:
await resolve_notebook(client, "Acme - Competitive Intel")
assert [c["id"] for c in caught.value.candidates] == ["deadbeef"]
async def test_exact_miss_with_no_near_match_has_empty_candidates() -> None:
client = _client(notebooks=[_NB("deadbeef", "Alpha"), _NB("cafef00d", "Beta")])
with pytest.raises(NotebookNotFoundError) as caught:
await resolve_notebook(client, "Zzzzqwx")
assert list(caught.value.candidates) == []
async def test_source_near_miss_attaches_candidate() -> None:
client = _client(sources=[_Src("src0001", "Quarterly — Revenue Deck")])
with pytest.raises(SourceNotFoundError) as caught:
await resolve_source(client, "nb", "Quarterly - Revenue Deck")
assert [c["id"] for c in caught.value.candidates] == ["src0001"]
@pytest.mark.parametrize("title", ["beef", "ABBA", "1234", "DEADBEEF"])
async def test_hex_only_title_falls_back_to_title(title: str) -> None:
"""A notebook whose TITLE is all-hex resolves by name (id/prefix path misses)."""
client = _client(notebooks=[_NB("0000aaaa1111", title), _NB("cafef00d", "Other")])
assert await resolve_notebook(client, title) == "0000aaaa1111"
async def test_hex_token_prefers_id_over_title() -> None:
"""When a hex token is BOTH a valid id-prefix and a title, the id/prefix wins."""
client = _client(notebooks=[_NB("beef0001", "Real Title"), _NB("cafef00d", "beef")])
# 'beef' is a unique id-prefix of beef0001 *and* the title of cafef00d; the
# id/prefix path must win, so the result is beef0001 (not cafef00d).
assert await resolve_notebook(client, "beef") == "beef0001"
async def test_ambiguous_hex_prefix_does_not_fall_back_to_title() -> None:
"""An ambiguous hex PREFIX raises AmbiguousIdError — it never falls to title."""
client = _client(
notebooks=[_NB("beef0001", "A"), _NB("beef0002", "B"), _NB("cafef00d", "beef")]
)
with pytest.raises(AmbiguousIdError) as caught:
await resolve_notebook(client, "beef")
assert set(caught.value.candidate_ids) == {"beef0001", "beef0002"}
async def test_hex_token_matching_neither_id_nor_title_raises_not_found() -> None:
"""A hex token that is neither an id-prefix nor a title still raises NotFound."""
client = _client(notebooks=[_NB("cafef00d", "Alpha")])
with pytest.raises(NotebookNotFoundError):
await resolve_notebook(client, "beef")
# --------------------------------------------------------------------------- #
# resolve_notebook — unique TITLE-prefix resolution (#1786)
# --------------------------------------------------------------------------- #
async def test_unique_title_prefix_resolves() -> None:
"""A unique title PREFIX resolves like an id prefix does (issue #1786 repro)."""
client = _client(
notebooks=[
_NB("deadbeef", "Scientific PDF Parsing — Landscape, Benchmarks & Extraction"),
_NB("cafef00d", "Marketing Plan"),
]
)
assert await resolve_notebook(client, "Scientific") == "deadbeef"
async def test_unique_title_prefix_is_case_insensitive() -> None:
"""The prefix match casefolds, so 'sci' resolves a 'Scientific …' title."""
client = _client(notebooks=[_NB("deadbeef", "Scientific Report"), _NB("cafef00d", "Other")])
assert await resolve_notebook(client, "sci") == "deadbeef"
async def test_ambiguous_title_prefix_raises_with_candidates() -> None:
"""An ambiguous title prefix raises AmbiguousIdError listing every candidate."""
client = _client(notebooks=[_NB("deadbeef", "Report Q1"), _NB("cafef00d", "Report Q2")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_notebook(client, "Report")
assert set(caught.value.candidate_ids) == {"deadbeef", "cafef00d"}
# The message names the axis that was ambiguous so the caller can react.
assert "prefix" in str(caught.value)
async def test_exact_title_wins_over_prefix() -> None:
"""A title that is ALSO a prefix of a longer one resolves to itself, not ambiguous."""
client = _client(notebooks=[_NB("deadbeef", "Report"), _NB("cafef00d", "Report Q1")])
assert await resolve_notebook(client, "Report") == "deadbeef"
async def test_partial_that_is_not_a_prefix_raises_not_found() -> None:
"""A token that is neither exact nor a prefix of any title still raises NOT_FOUND."""
client = _client(notebooks=[_NB("deadbeef", "Scientific Report"), _NB("cafef00d", "Other")])
with pytest.raises(NotebookNotFoundError):
await resolve_notebook(client, "Scientif Report") # interior gap, not a prefix
async def test_exact_title_ambiguity_short_circuits_before_prefix() -> None:
"""Two exact-title collisions raise as a *title* ambiguity, never reaching the prefix pass.
With titles "Report" x2 plus a longer "Report Q1", token "Report" has two
EXACT matches — that must raise before the prefix pass (which would otherwise
see all three). The message names the ``title`` axis, not ``prefix``.
"""
client = _client(
notebooks=[
_NB("deadbeef", "Report"),
_NB("cafef00d", "report"), # casefold-equal exact match
_NB("f00dcafe", "Report Q1"), # prefix-only; must be excluded from the error
]
)
with pytest.raises(AmbiguousIdError) as caught:
await resolve_notebook(client, "Report")
assert set(caught.value.candidate_ids) == {"deadbeef", "cafef00d"}
assert "title" in str(caught.value)
async def test_hex_token_falls_back_to_title_prefix() -> None:
"""A hex-ish token that is not an id-prefix falls back to a unique title *prefix*.
'bee' is hex-ish so it takes the id/prefix path first; no id starts with it,
so it falls through to the title path, which now prefix-matches "beef report".
"""
client = _client(notebooks=[_NB("0000aaaa1111", "beef report"), _NB("cafef00d", "Other")])
assert await resolve_notebook(client, "bee") == "0000aaaa1111"
# --------------------------------------------------------------------------- #
# resolve_source
# --------------------------------------------------------------------------- #
async def test_source_full_uuid_skips_list() -> None:
client = _client(sources=[_Src(FULL_A, "Doc")])
assert await resolve_source(client, "nb-1", FULL_A) == FULL_A
client.sources.list.assert_not_called()
async def test_source_prefix_match_lists_within_notebook() -> None:
client = _client(sources=[_Src("ab0001cdef", "Doc"), _Src("cd0002abef", "Doc2")])
assert await resolve_source(client, "nb-1", "ab0001") == "ab0001cdef"
client.sources.list.assert_awaited_once_with("nb-1")
async def test_source_title_match() -> None:
client = _client(sources=[_Src("ab0001cdef", "Report.pdf"), _Src("cd0002abef", "Notes")])
assert await resolve_source(client, "nb-1", "report.pdf") == "ab0001cdef"
async def test_source_ambiguous_title_raises() -> None:
client = _client(sources=[_Src("ab0001cdef", "Dup"), _Src("cd0002abef", "dup")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_source(client, "nb-1", "Dup")
assert set(caught.value.candidate_ids) == {"ab0001cdef", "cd0002abef"}
async def test_source_unique_title_prefix_resolves() -> None:
"""Title-prefix resolution is shared, so sources gain it too (#1786 parity)."""
client = _client(sources=[_Src("ab0001cdef", "Report.pdf"), _Src("cd0002abef", "Notes.txt")])
assert await resolve_source(client, "nb-1", "Report") == "ab0001cdef"
async def test_source_none_title_is_skipped_by_prefix() -> None:
"""A source with no title cannot be prefix-matched (``None`` folds to '')."""
client = _client(sources=[_Src("ab0001cdef", None), _Src("cd0002abef", "Report.pdf")])
assert await resolve_source(client, "nb-1", "Rep") == "cd0002abef"
with pytest.raises(SourceNotFoundError):
await resolve_source(client, "nb-1", "Untitled")
async def test_source_no_match_raises_source_not_found() -> None:
client = _client(sources=[_Src("ab0001cdef", "Doc")])
with pytest.raises(SourceNotFoundError):
await resolve_source(client, "nb-1", "Missing Title")
async def test_source_title_match_skips_none_titled() -> None:
"""A source with no title cannot match a title query."""
client = _client(sources=[_Src("ab0001cdef", None), _Src("cd0002abef", "Real")])
assert await resolve_source(client, "nb-1", "Real") == "cd0002abef"
async def test_source_hex_only_title_falls_back_to_title() -> None:
"""A source whose TITLE is all-hex resolves by name (id/prefix path misses)."""
client = _client(sources=[_Src("0000aaaa1111", "beef"), _Src("cd0002abef", "Notes")])
assert await resolve_source(client, "nb-1", "beef") == "0000aaaa1111"
# --------------------------------------------------------------------------- #
# resolve_sources (batch — lists at most once)
# --------------------------------------------------------------------------- #
async def test_sources_all_full_uuid_skips_list() -> None:
"""A batch of full UUIDs is returned verbatim with no list call."""
client = _client(sources=[_Src(FULL_A, "Doc")])
assert await resolve_sources(client, "nb-1", [FULL_A, FULL_B]) == [FULL_A, FULL_B]
client.sources.list.assert_not_called()
async def test_sources_mixed_lists_exactly_once_order_preserved() -> None:
"""A mix of prefix/title refs lists once and preserves input order."""
client = _client(
sources=[
_Src("ab0001cdef", "Report.pdf"),
_Src("cd0002abef", "Notes"),
]
)
result = await resolve_sources(client, "nb-1", ["Notes", "ab0001", FULL_A])
assert result == ["cd0002abef", "ab0001cdef", FULL_A]
client.sources.list.assert_awaited_once_with("nb-1")
async def test_sources_title_refs_list_exactly_once() -> None:
"""Two non-UUID title refs share a single list snapshot, order preserved."""
client = _client(
sources=[
_Src("ab0001cdef", "Report.pdf"),
_Src("cd0002abef", "Notes"),
]
)
result = await resolve_sources(client, "nb-1", ["Notes", "Report.pdf"])
assert result == ["cd0002abef", "ab0001cdef"]
client.sources.list.assert_awaited_once_with("nb-1")
async def test_sources_unique_title_prefix_resolves_batch() -> None:
"""Batch resolution shares the same helper, so title prefixes resolve in a batch too."""
client = _client(sources=[_Src("ab0001cdef", "Report.pdf"), _Src("cd0002abef", "Notes.txt")])
result = await resolve_sources(client, "nb-1", ["Report", "Notes"])
assert result == ["ab0001cdef", "cd0002abef"]
client.sources.list.assert_awaited_once_with("nb-1")
async def test_sources_no_match_raises_source_not_found() -> None:
client = _client(sources=[_Src("ab0001cdef", "Doc")])
with pytest.raises(SourceNotFoundError):
await resolve_sources(client, "nb-1", ["Doc", "Missing Title"])
async def test_sources_ambiguous_title_raises() -> None:
client = _client(sources=[_Src("ab0001cdef", "Dup"), _Src("cd0002abef", "dup")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_sources(client, "nb-1", ["Dup"])
assert set(caught.value.candidate_ids) == {"ab0001cdef", "cd0002abef"}
async def test_sources_whitespace_ref_raises_validation_before_listing() -> None:
"""Every ref is ``validate_id``-checked first, so an empty/whitespace ref in the
batch raises ``ValidationError`` (no list call, per the documented contract)."""
client = _client(sources=[_Src("ab0001cdef", "Doc")])
with pytest.raises(ValidationError):
await resolve_sources(client, "nb-1", ["Doc", " "])
client.sources.list.assert_not_called()
# --------------------------------------------------------------------------- #
# resolve_artifact
# --------------------------------------------------------------------------- #
async def test_artifact_full_uuid_skips_list() -> None:
"""A full canonical UUID is returned verbatim with no artifact-list call.
This is load-bearing: a note-backed mind-map id (or one missing from a stale
list) must still reach the ``_app`` core for kind routing.
"""
client = _client(artifacts=[_Art(FULL_A, "Podcast")])
assert await resolve_artifact(client, "nb-1", FULL_A) == FULL_A
client.artifacts.list.assert_not_called()
async def test_artifact_prefix_match_lists_within_notebook() -> None:
client = _client(artifacts=[_Art("ab0001cdef", "Podcast"), _Art("cd0002abef", "Quiz")])
assert await resolve_artifact(client, "nb-1", "ab0001") == "ab0001cdef"
client.artifacts.list.assert_awaited_once_with("nb-1")
async def test_artifact_title_match() -> None:
client = _client(artifacts=[_Art("ab0001cdef", "My Podcast"), _Art("cd0002abef", "Quiz")])
assert await resolve_artifact(client, "nb-1", "my podcast") == "ab0001cdef"
async def test_artifact_ambiguous_title_raises() -> None:
client = _client(artifacts=[_Art("ab0001cdef", "Dup"), _Art("cd0002abef", "dup")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_artifact(client, "nb-1", "Dup")
assert set(caught.value.candidate_ids) == {"ab0001cdef", "cd0002abef"}
async def test_artifact_ambiguous_prefix_raises() -> None:
client = _client(artifacts=[_Art("beef0001", "A"), _Art("beef0002", "B")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_artifact(client, "nb-1", "beef")
assert set(caught.value.candidate_ids) == {"beef0001", "beef0002"}
async def test_artifact_unique_title_prefix_resolves() -> None:
"""Title-prefix resolution is shared, so artifacts gain it too (#1786 parity)."""
client = _client(
artifacts=[_Art("ab0001cdef", "My Podcast"), _Art("cd0002abef", "Weekly Quiz")]
)
assert await resolve_artifact(client, "nb-1", "My Pod") == "ab0001cdef"
async def test_artifact_no_match_raises_artifact_not_found() -> None:
client = _client(artifacts=[_Art("ab0001cdef", "Podcast")])
with pytest.raises(ArtifactNotFoundError):
await resolve_artifact(client, "nb-1", "Missing Title")
async def test_artifact_hex_only_title_falls_back_to_title() -> None:
"""An artifact whose TITLE is all-hex resolves by name (id/prefix path misses)."""
client = _client(artifacts=[_Art("0000aaaa1111", "beef"), _Art("cd0002abef", "Quiz")])
assert await resolve_artifact(client, "nb-1", "beef") == "0000aaaa1111"
async def test_artifact_whitespace_ref_raises_validation_before_listing() -> None:
"""An empty/whitespace ref is ``validate_id``-rejected before any list call
(sibling-resolver parity)."""
client = _client(artifacts=[_Art("ab0001cdef", "Podcast")])
with pytest.raises(ValidationError):
await resolve_artifact(client, "nb-1", " ")
client.artifacts.list.assert_not_called()
# --------------------------------------------------------------------------- #
# resolve_note (fourth resolver family — shares the same helper)
# --------------------------------------------------------------------------- #
async def test_note_title_match() -> None:
client = _client(notes=[_Note("ab0001cdef", "Meeting Notes"), _Note("cd0002abef", "Tasks")])
assert await resolve_note(client, "nb-1", "meeting notes") == "ab0001cdef"
async def test_note_unique_title_prefix_resolves() -> None:
"""Title-prefix resolution is shared, so notes gain it too (#1786 parity)."""
client = _client(notes=[_Note("ab0001cdef", "Meeting Notes"), _Note("cd0002abef", "Tasks")])
assert await resolve_note(client, "nb-1", "Meeting") == "ab0001cdef"
async def test_note_ambiguous_title_prefix_raises() -> None:
client = _client(notes=[_Note("ab0001cdef", "Report Q1"), _Note("cd0002abef", "Report Q2")])
with pytest.raises(AmbiguousIdError) as caught:
await resolve_note(client, "nb-1", "Report")
assert set(caught.value.candidate_ids) == {"ab0001cdef", "cd0002abef"}
assert "prefix" in str(caught.value)
async def test_note_no_match_raises_note_not_found() -> None:
client = _client(notes=[_Note("ab0001cdef", "Meeting Notes")])
with pytest.raises(NoteNotFoundError):
await resolve_note(client, "nb-1", "Missing Title")
# --------------------------------------------------------------------------- #
# Strict IDs-only mode (NOTEBOOKLM_MCP_STRICT_IDS=1) — issue #1808
#
# When enabled, every resolver rejects any reference that is not a full canonical
# UUID (a name, title, or short id *prefix*), BEFORE any list call, so long-lived
# automation fails loud and deterministically instead of fuzzy-matching. Off by
# default, so the tests above (which do not set the env var) are the "strict off"
# behavior contract; these assert the opt-in guard.
# --------------------------------------------------------------------------- #
@pytest.fixture
def strict_ids(monkeypatch: pytest.MonkeyPatch) -> None:
"""Enable strict IDs-only mode for the duration of a test."""
monkeypatch.setenv("NOTEBOOKLM_MCP_STRICT_IDS", "1")
async def test_strict_full_uuid_still_resolves_without_listing(strict_ids: None) -> None:
"""A full canonical UUID is the one accepted form — and still skips the list."""
client = _client(
notebooks=[_NB(FULL_A, "Alpha")],
sources=[_Src(FULL_A, "S")],
artifacts=[_Art(FULL_A, "A")],
notes=[_Note(FULL_A, "N")],
)
assert await resolve_notebook(client, FULL_A) == FULL_A
assert await resolve_source(client, "nb-1", FULL_A) == FULL_A
assert await resolve_artifact(client, "nb-1", FULL_A) == FULL_A
assert await resolve_note(client, "nb-1", FULL_A) == FULL_A
client.notebooks.list.assert_not_called()
client.sources.list.assert_not_called()
client.artifacts.list.assert_not_called()
client.notes.list.assert_not_called()
async def test_strict_rejects_name_before_listing(strict_ids: None) -> None:
"""A title/name is rejected with ValidationError and no list call is made."""
client = _client(notebooks=[_NB(FULL_A, "Alpha")])
with pytest.raises(ValidationError) as caught:
await resolve_notebook(client, "Alpha")
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(caught.value)
client.notebooks.list.assert_not_called()
async def test_strict_rejects_short_id_prefix(strict_ids: None) -> None:
"""Even a would-be-unique short id prefix is rejected (only full UUIDs pass)."""
client = _client(notebooks=[_NB(FULL_A, "Alpha")])
with pytest.raises(ValidationError):
await resolve_notebook(client, FULL_A[:8])
client.notebooks.list.assert_not_called()
@pytest.mark.parametrize(
("resolve", "kind"),
[
(lambda c, t: resolve_source(c, "nb-1", t), "sources"),
(lambda c, t: resolve_artifact(c, "nb-1", t), "artifacts"),
(lambda c, t: resolve_note(c, "nb-1", t), "notes"),
],
)
async def test_strict_rejects_name_on_child_resolvers(strict_ids: None, resolve, kind: str) -> None:
"""Source / artifact / note resolvers also reject a name before listing."""
client = _client(
sources=[_Src(FULL_A, "Title")],
artifacts=[_Art(FULL_A, "Title")],
notes=[_Note(FULL_A, "Title")],
)
with pytest.raises(ValidationError):
await resolve(client, "Title")
getattr(client, kind).list.assert_not_called()
async def test_strict_resolve_sources_all_uuid_batch_skips_list(strict_ids: None) -> None:
"""An all-full-UUID batch resolves with no list call, as when strict is off."""
client = _client()
assert await resolve_sources(client, "nb-1", [FULL_A, FULL_B]) == [FULL_A, FULL_B]
client.sources.list.assert_not_called()
async def test_strict_resolve_sources_mixed_batch_rejects_before_list(strict_ids: None) -> None:
"""A mixed UUID/name batch rejects BEFORE the source list RPC (no leak)."""
client = _client(sources=[_Src(FULL_A, "Alpha")])
with pytest.raises(ValidationError):
await resolve_sources(client, "nb-1", [FULL_A, "Alpha"])
client.sources.list.assert_not_awaited()
async def test_strict_resolve_sources_empty_batch_returns_empty(strict_ids: None) -> None:
"""An empty batch is still an empty list (nothing to reject), no list call."""
client = _client()
assert await resolve_sources(client, "nb-1", []) == []
client.sources.list.assert_not_called()
async def test_strict_off_still_resolves_names() -> None:
"""Without the env var, name resolution is unchanged (the default contract)."""
client = _client(notebooks=[_NB(FULL_A, "Alpha")])
assert await resolve_notebook(client, "Alpha") == FULL_A