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
1941 lines
84 KiB
Python
1941 lines
84 KiB
Python
"""Unit tests for the artifact MCP tools.
|
||
|
||
Drives each tool through the in-memory FastMCP ``Client`` against a server bound
|
||
to the mocked ``NotebookLMClient``, asserting the serialized
|
||
``structured_content``. Covers each tool's happy path, name-vs-id resolution
|
||
reaching the tool, the per-``type`` ``studio_generate`` / ``studio_download``
|
||
enum dispatch, the start→status poll shape, and error projection.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
# Skip cleanly when the `mcp` extra (fastmcp) is absent; see conftest.py.
|
||
pytest.importorskip("fastmcp")
|
||
|
||
from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip guard
|
||
|
||
from notebooklm._types.artifacts import ( # noqa: E402
|
||
QUIZ_VARIANT,
|
||
ArtifactStatus,
|
||
ArtifactTypeCode,
|
||
)
|
||
from notebooklm._types.mind_maps import MindMapKind # noqa: E402
|
||
from notebooklm.exceptions import ( # noqa: E402 - after importorskip guard
|
||
ArtifactNotFoundError,
|
||
NotebookNotFoundError,
|
||
RateLimitError,
|
||
)
|
||
from notebooklm.mcp.tools.studio import _KIND_OPTIONS # noqa: E402
|
||
from notebooklm.types import Artifact, ArtifactType, GenerationState, Note # noqa: E402
|
||
|
||
from .conftest import AsyncMock # noqa: E402 - after importorskip guard
|
||
|
||
NB_ID = "11111111-1111-1111-1111-111111111111"
|
||
TASK_ID = "task-abc-123"
|
||
|
||
|
||
def _schema_enum(prop: dict[str, Any]) -> set[str] | None:
|
||
"""The JSON-schema ``enum`` for a tool param, or ``None`` if it has none.
|
||
|
||
Handles BOTH shapes FastMCP/Pydantic emits: a required ``Literal`` renders a
|
||
flat ``{"enum": [...]}``; an optional ``Literal[...] | None`` renders
|
||
``{"anyOf": [{"enum": [...], "type": "string"}, {"type": "null"}]}``. A
|
||
free-text ``str``/``str | None`` param has no ``enum`` branch → ``None``.
|
||
"""
|
||
if "enum" in prop:
|
||
return set(prop["enum"])
|
||
# ``anyOf`` is Pydantic v2's shape for ``T | None`` today; also scan ``oneOf``
|
||
# so the helper survives a future schema-generation switch to the JSON-Schema
|
||
# mutually-exclusive form rather than silently returning ``None``.
|
||
for branch in (prop.get("anyOf") or []) + (prop.get("oneOf") or []):
|
||
if "enum" in branch:
|
||
return set(branch["enum"])
|
||
return None
|
||
|
||
|
||
#: Real-``Artifact`` builders for the download core (it filters on
|
||
#: ``isinstance(a, Artifact)`` + the int type code + ``is_completed``).
|
||
_AUDIO_ARTIFACT = Artifact(
|
||
id="art1",
|
||
title="Podcast",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
_QUIZ_ARTIFACT = Artifact(
|
||
id="q1",
|
||
title="Quiz",
|
||
_artifact_type=ArtifactTypeCode.QUIZ.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
_variant=QUIZ_VARIANT,
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class FakeArtifact:
|
||
id: str
|
||
title: str
|
||
kind: ArtifactType = ArtifactType.AUDIO
|
||
is_completed: bool = True
|
||
created_at: datetime = field(default_factory=lambda: datetime(2024, 1, 1, tzinfo=timezone.utc))
|
||
|
||
|
||
@dataclass
|
||
class FakeMindMap:
|
||
"""Minimal ``MindMap`` stand-in for the rename/delete mind-map probes.
|
||
|
||
``rename_artifact`` reads ``.id`` + ``.kind`` off ``mind_maps.list`` rows;
|
||
``delete_artifact`` reads ``.id`` off ``mind_maps.list_note_backed`` rows.
|
||
"""
|
||
|
||
id: str
|
||
kind: MindMapKind = MindMapKind.INTERACTIVE
|
||
|
||
|
||
@dataclass
|
||
class FakeStatus:
|
||
task_id: str
|
||
status: GenerationState = GenerationState.COMPLETED
|
||
url: str | None = "https://example.com/out.mp3"
|
||
error: str | None = None
|
||
error_code: str | None = None
|
||
metadata: dict[str, Any] | None = None
|
||
|
||
@property
|
||
def is_complete(self) -> bool:
|
||
return self.status == GenerationState.COMPLETED
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_list
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class FakeNote:
|
||
"""Minimal ``Note`` stand-in for the merged ``studio_list`` projection."""
|
||
|
||
id: str
|
||
title: str
|
||
content: str = ""
|
||
created_at: datetime | None = None
|
||
|
||
|
||
#: Ids used across the merged studio_list / studio_delete tests.
|
||
_NOTE_ID = "dddddddd-dddd-dddd-dddd-dddddddddddd"
|
||
|
||
|
||
def _completed_artifact(art_id: str, title: str) -> Artifact:
|
||
"""A real completed audio ``Artifact`` (carries ``.kind`` / ``.status_str`` / ``.url``)."""
|
||
return Artifact(
|
||
id=art_id,
|
||
title=title,
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
|
||
|
||
async def test_studio_list_merges_notes_and_artifacts(mcp_call, mock_client) -> None:
|
||
"""``studio_list`` merges text notes AND artifacts into one ``items`` list."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID})
|
||
sc = result.structured_content
|
||
assert sc["notebook_id"] == NB_ID
|
||
items = sc["items"]
|
||
by_type = {it["type"]: it for it in items}
|
||
# A text note item — the default (summary) list gives a bounded preview + the
|
||
# full-body char_count, NOT the full ``content`` key (discovery token-saver).
|
||
assert by_type["note"]["id"] == _NOTE_ID
|
||
assert by_type["note"]["content_preview"] == "body"
|
||
assert by_type["note"]["char_count"] == 4
|
||
assert "content" not in by_type["note"]
|
||
# … and an artifact item (hyphenated type + status_label + url).
|
||
assert by_type["audio"]["id"] == "art1"
|
||
assert by_type["audio"]["status_label"] == "completed"
|
||
assert "url" in by_type["audio"]
|
||
# Pagination meta is pinned (key is ``items``, never ``notes``/``artifacts``).
|
||
assert sc["total"] == 2
|
||
assert sc["offset"] == 0
|
||
assert sc["has_more"] is False
|
||
mock_client.notes.list.assert_awaited_once_with(NB_ID)
|
||
mock_client.artifacts.list.assert_awaited_once_with(NB_ID)
|
||
|
||
|
||
async def test_studio_list_resolves_notebook_by_name(mcp_call, mock_client) -> None:
|
||
@dataclass
|
||
class FakeNotebook:
|
||
id: str
|
||
title: str
|
||
|
||
mock_client.notebooks.list = AsyncMock(
|
||
return_value=[FakeNotebook(id=NB_ID, title="My Notebook")]
|
||
)
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
result = await mcp_call("studio_list", {"notebook": "My Notebook"})
|
||
assert result.structured_content["notebook_id"] == NB_ID
|
||
assert result.structured_content["items"] == []
|
||
mock_client.artifacts.list.assert_awaited_with(NB_ID)
|
||
|
||
|
||
async def test_studio_list_item_single_fetch(mcp_call, mock_client) -> None:
|
||
"""``studio_list(item=…)`` returns just the matched item as a 1-element list."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID, "item": "My Podcast"})
|
||
sc = result.structured_content
|
||
assert (sc["total"], sc["offset"], sc["has_more"]) == (1, 0, False)
|
||
assert len(sc["items"]) == 1
|
||
assert sc["items"][0]["id"] == "art1"
|
||
assert sc["items"][0]["type"] == "audio"
|
||
|
||
|
||
async def test_studio_list_item_not_found_projects_tool_error(mcp_call, mock_client) -> None:
|
||
"""A ref that matches no note or artifact is a NOT_FOUND error."""
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_list", {"notebook": NB_ID, "item": "No Such Thing"})
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
|
||
|
||
async def test_studio_list_kind_filter(mcp_call, mock_client) -> None:
|
||
"""``kind`` filters the merged list to one ``type``."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID, "kind": "note"})
|
||
items = result.structured_content["items"]
|
||
assert len(items) == 1
|
||
assert items[0]["type"] == "note"
|
||
assert items[0]["id"] == _NOTE_ID
|
||
|
||
|
||
@pytest.mark.parametrize("bad", ["mind_map", "slide_deck", "note-backed", "bogus", "unknown"])
|
||
async def test_studio_list_rejects_unknown_kind(mcp_call, mock_client, bad) -> None:
|
||
"""An unknown/underscored ``kind`` rejects at the ``Literal`` schema boundary
|
||
(pydantic ``literal_error``), NOT via the old runtime ``"unknown kind"`` VALIDATION
|
||
path — mirroring ``studio_generate``'s out-of-enum option rejection. ``unknown`` is a
|
||
display-only pass-through value, not a filterable kind, so it's rejected too; the
|
||
underscored forms (``mind_map`` / ``slide_deck``) are not the hyphenated members."""
|
||
with pytest.raises(ToolError) as exc:
|
||
await mcp_call("studio_list", {"notebook": NB_ID, "kind": bad})
|
||
msg = str(exc.value)
|
||
assert "literal_error" in msg
|
||
assert "VALIDATION" not in msg
|
||
|
||
|
||
async def test_studio_list_kind_enum_matches_studio_kinds(mcp_list_tools) -> None:
|
||
"""The ``kind`` param's schema ``enum`` is pinned equal to ``STUDIO_KINDS`` so the
|
||
hand-spelled signature ``Literal`` can't drift from the runtime source of truth
|
||
(``STUDIO_KINDS`` is a frozenset, so it can't BE the ``Literal`` directly).
|
||
|
||
Also asserts ``"cinematic-video"`` is absent: it is a ``studio_generate.artifact_type``
|
||
member but NOT an ``ArtifactType`` / studio kind, so a future ``ArtifactType`` addition
|
||
can't silently widen this filter via a stale Literal."""
|
||
from notebooklm.mcp.tools._studio_items import STUDIO_KINDS
|
||
|
||
tools = await mcp_list_tools()
|
||
schema = next(t for t in tools if t.name == "studio_list").inputSchema
|
||
enum = _schema_enum(schema["properties"]["kind"])
|
||
assert enum == STUDIO_KINDS
|
||
assert enum is not None and len(enum) == 10
|
||
assert "cinematic-video" not in enum
|
||
|
||
|
||
async def test_studio_list_summary_truncates_long_note(mcp_call, mock_client) -> None:
|
||
"""A note longer than NOTE_PREVIEW_CHARS → preview capped at NOTE_PREVIEW_CHARS + ``…``,
|
||
``char_count`` is the FULL body length, and the full ``content`` key is dropped."""
|
||
from notebooklm.mcp.tools._studio_items import NOTE_PREVIEW_CHARS
|
||
|
||
body = "x" * (NOTE_PREVIEW_CHARS + 50)
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="Long", content=body)]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID})
|
||
note = result.structured_content["items"][0]
|
||
assert note["content_preview"] == "x" * NOTE_PREVIEW_CHARS + "…"
|
||
assert note["char_count"] == NOTE_PREVIEW_CHARS + 50
|
||
assert "content" not in note
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body, expected_preview, expected_count",
|
||
[
|
||
pytest.param("y" * 200, "y" * 200, 200, id="exactly-preview-chars-no-ellipsis"),
|
||
pytest.param("", "", 0, id="empty-body"),
|
||
pytest.param(None, "", 0, id="none-body"),
|
||
],
|
||
)
|
||
async def test_studio_list_summary_boundary_and_empty(
|
||
mcp_call, mock_client, body, expected_preview, expected_count
|
||
) -> None:
|
||
"""Boundary/empty note bodies: exactly NOTE_PREVIEW_CHARS chars → no ``…``; an empty
|
||
or ``None`` body → ``content_preview=""`` / ``char_count=0`` (no crash)."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="Edge", content=body)]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID})
|
||
note = result.structured_content["items"][0]
|
||
assert note["content_preview"] == expected_preview
|
||
assert note["char_count"] == expected_count
|
||
assert "content" not in note
|
||
|
||
|
||
async def test_studio_list_detail_full_returns_body(mcp_call, mock_client) -> None:
|
||
"""``detail="full"`` returns each note's full ``content`` (no preview/char_count)."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="the full body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "full"})
|
||
note = result.structured_content["items"][0]
|
||
assert note["content"] == "the full body"
|
||
assert "content_preview" not in note
|
||
assert "char_count" not in note
|
||
|
||
|
||
async def test_studio_list_item_single_fetch_returns_full_body(mcp_call, mock_client) -> None:
|
||
"""``item=<note ref>`` returns the note's FULL ``content`` even under the default
|
||
summary mode — the single-fetch path is how a full body stays reachable."""
|
||
body = "z" * 500
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content=body)]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID, "item": "My Note"})
|
||
note = result.structured_content["items"][0]
|
||
assert note["content"] == body
|
||
assert "content_preview" not in note
|
||
|
||
|
||
async def test_studio_list_summary_leaves_artifacts_untouched(mcp_call, mock_client) -> None:
|
||
"""An artifact item (no body) is identical in summary vs full — no preview/char_count."""
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
|
||
summary = await mcp_call("studio_list", {"notebook": NB_ID})
|
||
full = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "full"})
|
||
assert summary.structured_content["items"] == full.structured_content["items"]
|
||
art = summary.structured_content["items"][0]
|
||
assert "content_preview" not in art
|
||
assert "char_count" not in art
|
||
|
||
|
||
async def test_studio_list_rejects_bad_detail(mcp_call, mock_client) -> None:
|
||
"""A ``detail`` outside the compact|summary|full enum is rejected at the schema
|
||
boundary (mirrors ``source_read``'s invalid-``detail`` test)."""
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
with pytest.raises(ToolError):
|
||
await mcp_call("studio_list", {"notebook": NB_ID, "detail": "bogus"})
|
||
|
||
|
||
async def test_studio_list_compact(mcp_call, mock_client) -> None:
|
||
"""``detail="compact"`` projects every item — note AND artifact — to a uniform
|
||
5-field roster row (``id, title, type, status_label, created_at``), no body/url.
|
||
|
||
A note carries its real ``created_at`` but no status (``status_label=None``); an
|
||
artifact carries both. ``created_at`` is already-fetched data the default
|
||
projection drops."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[
|
||
FakeNote(
|
||
id=_NOTE_ID,
|
||
title="My Note",
|
||
content="x" * 500,
|
||
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||
)
|
||
]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "compact"})
|
||
items = {it["type"]: it for it in result.structured_content["items"]}
|
||
assert set(items["note"]) == {"id", "title", "type", "status_label", "created_at"}
|
||
assert items["note"] == {
|
||
"id": _NOTE_ID,
|
||
"title": "My Note",
|
||
"type": "note",
|
||
"status_label": None,
|
||
"created_at": "2024-01-02T00:00:00+00:00",
|
||
}
|
||
assert set(items["audio"]) == {"id", "title", "type", "status_label", "created_at"}
|
||
assert items["audio"]["status_label"] == "completed"
|
||
assert items["audio"]["created_at"] == "2024-01-01T00:00:00+00:00"
|
||
|
||
|
||
async def test_studio_list_compact_null_created_at(mcp_call, mock_client) -> None:
|
||
"""A still-processing artifact (``created_at=None``) serializes ``created_at`` to
|
||
``None`` in the compact row — mirrors the source-side null test."""
|
||
art = Artifact(
|
||
id="art1",
|
||
title="Generating",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.PROCESSING),
|
||
created_at=None,
|
||
)
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[art])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "compact"})
|
||
row = result.structured_content["items"][0]
|
||
assert set(row) == {"id", "title", "type", "status_label", "created_at"}
|
||
assert row["created_at"] is None
|
||
|
||
|
||
async def test_studio_list_compact_composes_with_kind_filter(mcp_call, mock_client) -> None:
|
||
"""``detail="compact"`` still honors the ``kind`` filter (shaping is orthogonal)."""
|
||
mock_client.notes.list = AsyncMock(return_value=[FakeNote(id=_NOTE_ID, title="N", content="b")])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "Pod")])
|
||
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "compact", "kind": "note"})
|
||
rows = result.structured_content["items"]
|
||
assert [r["type"] for r in rows] == ["note"]
|
||
assert set(rows[0]) == {"id", "title", "type", "status_label", "created_at"}
|
||
|
||
|
||
async def test_studio_list_compact_item_path_unaffected(mcp_call, mock_client) -> None:
|
||
"""``item=<ref>`` returns the full item even with ``detail="compact"`` — the
|
||
single-fetch path ignores ``detail`` (unchanged contract)."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="the full body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
result = await mcp_call(
|
||
"studio_list", {"notebook": NB_ID, "item": "My Note", "detail": "compact"}
|
||
)
|
||
note = result.structured_content["items"][0]
|
||
assert note["content"] == "the full body"
|
||
assert "created_at" not in note
|
||
|
||
|
||
async def test_studio_items_created_at_opt_in() -> None:
|
||
"""``studio_items`` omits ``created_at`` by default (default paths byte-identical)
|
||
and includes it only when ``include_created_at=True``."""
|
||
from notebooklm.mcp.tools._studio_items import studio_items
|
||
|
||
client = MagicMock()
|
||
client.notes.list = AsyncMock(
|
||
return_value=[
|
||
FakeNote(
|
||
id=_NOTE_ID,
|
||
title="N",
|
||
content="b",
|
||
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||
)
|
||
]
|
||
)
|
||
client.artifacts.list = AsyncMock(return_value=[])
|
||
default = await studio_items(client, NB_ID)
|
||
assert "created_at" not in default[0]
|
||
enriched = await studio_items(client, NB_ID, include_created_at=True)
|
||
assert enriched[0]["created_at"] == "2024-01-02T00:00:00+00:00"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_generate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_artifact_generate_audio(mcp_call, mock_client) -> None:
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
result = await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "audio"})
|
||
assert result.structured_content["kind"] == "audio"
|
||
assert result.structured_content["task_id"] == TASK_ID
|
||
mock_client.artifacts.generate_audio.assert_awaited_once()
|
||
# notebook id is the first positional arg.
|
||
assert mock_client.artifacts.generate_audio.await_args.args[0] == NB_ID
|
||
|
||
|
||
async def test_artifact_generate_quiz_routes_to_quiz(mcp_call, mock_client) -> None:
|
||
mock_client.artifacts.generate_quiz = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
result = await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "quiz"})
|
||
assert result.structured_content["kind"] == "quiz"
|
||
mock_client.artifacts.generate_quiz.assert_awaited_once()
|
||
|
||
|
||
async def test_artifact_generate_video_routes_to_video(mcp_call, mock_client) -> None:
|
||
mock_client.artifacts.generate_video = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "video"})
|
||
mock_client.artifacts.generate_video.assert_awaited_once()
|
||
|
||
|
||
async def test_artifact_generate_report_routes_to_report(mcp_call, mock_client) -> None:
|
||
mock_client.artifacts.generate_report = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "report", "report_format": "study-guide"},
|
||
)
|
||
mock_client.artifacts.generate_report.assert_awaited_once()
|
||
|
||
|
||
async def test_artifact_generate_passes_source_ids(mcp_call, mock_client) -> None:
|
||
# Full-UUID source ids take resolve_source's fast path (no listing) and pass
|
||
# straight through — the style MCP supplies.
|
||
src_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
src_b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
result = await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": [src_a, src_b]},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] == (src_a, src_b)
|
||
# A source-scoped generation echoes the resolved canonical source_ids (#1808).
|
||
assert result.structured_content["source_ids"] == [src_a, src_b]
|
||
|
||
|
||
async def test_artifact_generate_resolves_source_id_prefix(mcp_call, mock_client) -> None:
|
||
"""A non-UUID source ref is resolved to its full id (like every sibling tool),
|
||
not forwarded raw to the backend."""
|
||
|
||
@dataclass
|
||
class _Src:
|
||
id: str
|
||
title: str = "Doc"
|
||
|
||
full = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
mock_client.sources.list = AsyncMock(return_value=[_Src(id=full)])
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": [full[:12]]},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] == (full,)
|
||
|
||
|
||
async def test_artifact_generate_two_title_refs_list_once_order_preserved(
|
||
mcp_call, mock_client
|
||
) -> None:
|
||
"""Two non-UUID refs resolve via a single ``sources.list`` snapshot, in input order."""
|
||
|
||
@dataclass
|
||
class _Src:
|
||
id: str
|
||
title: str | None
|
||
|
||
src_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
src_b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||
mock_client.sources.list = AsyncMock(
|
||
return_value=[_Src(id=src_a, title="Alpha"), _Src(id=src_b, title="Beta")]
|
||
)
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": ["Beta", "Alpha"]},
|
||
)
|
||
mock_client.sources.list.assert_awaited_once_with(NB_ID)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] == (src_b, src_a)
|
||
|
||
|
||
async def test_artifact_generate_omitting_source_ids_uses_all(mcp_call, mock_client) -> None:
|
||
"""Omitting ``source_ids`` must pass ``source_ids=None`` (=> all sources), NOT an
|
||
empty tuple. An empty list reaches the backend as 'zero sources', which it refuses
|
||
for source-needing kinds (quiz/audio/flashcards), returning a null id surfaced as
|
||
'… generation is unavailable'."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "audio"})
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] is None
|
||
|
||
|
||
async def test_artifact_generate_empty_source_ids_uses_all(mcp_call, mock_client) -> None:
|
||
"""An EXPLICIT empty list is the same contract as omitting: => None (all sources),
|
||
never [] (which the backend refuses). Pins the full empty-vs-None contract."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate", {"notebook": NB_ID, "artifact_type": "audio", "source_ids": []}
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] is None
|
||
|
||
|
||
# Full-UUID source ids take resolve_source's fast path (no listing needed), so the
|
||
# string-shape coercion tests below need no ``sources.list`` mock.
|
||
_SRC_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
_SRC_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||
|
||
|
||
async def test_artifact_generate_source_ids_json_string(mcp_call, mock_client) -> None:
|
||
"""``source_ids`` sent as a JSON-array string is tolerated (coerce_list)."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": f'["{_SRC_A}","{_SRC_B}"]'},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] == (_SRC_A, _SRC_B)
|
||
|
||
|
||
async def test_artifact_generate_source_ids_comma_string(mcp_call, mock_client) -> None:
|
||
"""``source_ids`` sent as a comma-separated string is tolerated (coerce_list)."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": f"{_SRC_A},{_SRC_B}"},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] == (_SRC_A, _SRC_B)
|
||
|
||
|
||
async def test_artifact_generate_source_ids_scalar_string(mcp_call, mock_client) -> None:
|
||
"""``source_ids`` sent as a bare scalar string is tolerated (coerce_list)."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": _SRC_A},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] == (_SRC_A,)
|
||
|
||
|
||
async def test_artifact_generate_source_ids_empty_string_uses_all(mcp_call, mock_client) -> None:
|
||
"""An empty string coerces to [] => collapses to None (all sources)."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": ""},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] is None
|
||
|
||
|
||
async def test_artifact_generate_source_ids_whitespace_uses_all(mcp_call, mock_client) -> None:
|
||
"""A whitespace-only string coerces to [] => collapses to None (all sources)."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": " "},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
|
||
assert kwargs["source_ids"] is None
|
||
|
||
|
||
async def test_artifact_generate_unknown_type_is_validation_error(mcp_call, mock_client) -> None:
|
||
"""An unknown artifact_type is rejected at the Literal schema boundary."""
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "bogus"})
|
||
assert "audio" in str(excinfo.value) and "report" in str(excinfo.value)
|
||
|
||
|
||
async def test_artifact_generate_bad_language_is_validation_error(mcp_call, mock_client) -> None:
|
||
"""An unsupported ``language`` projects as VALIDATION up front (not forwarded raw)."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "language": "klingon"},
|
||
)
|
||
assert "VALIDATION" in str(excinfo.value)
|
||
mock_client.artifacts.generate_audio.assert_not_called()
|
||
|
||
|
||
async def test_artifact_generate_valid_language_passes(mcp_call, mock_client) -> None:
|
||
"""A supported language code is accepted and forwarded."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
result = await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "language": "es"},
|
||
)
|
||
assert result.structured_content["kind"] == "audio"
|
||
mock_client.artifacts.generate_audio.assert_awaited_once()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_generate — per-kind options (#1654)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_artifact_generate_video_options(mcp_call, mock_client) -> None:
|
||
"""video format/style/style_prompt all reach generate_video (custom style path)."""
|
||
mock_client.artifacts.generate_video = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "video",
|
||
"video_format": "brief",
|
||
"style": "custom",
|
||
"style_prompt": "hand-drawn diagrams",
|
||
},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_video.await_args.kwargs
|
||
assert kwargs["video_format"].name == "BRIEF"
|
||
assert kwargs["video_style"].name == "CUSTOM"
|
||
assert kwargs["style_prompt"] == "hand-drawn diagrams"
|
||
|
||
|
||
async def test_artifact_generate_slide_deck_options(mcp_call, mock_client) -> None:
|
||
mock_client.artifacts.generate_slide_deck = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "slide-deck",
|
||
"deck_format": "presenter",
|
||
"deck_length": "short",
|
||
},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_slide_deck.await_args.kwargs
|
||
assert kwargs["slide_format"].name == "PRESENTER_SLIDES"
|
||
assert kwargs["slide_length"].name == "SHORT"
|
||
|
||
|
||
async def test_artifact_generate_infographic_options(mcp_call, mock_client) -> None:
|
||
mock_client.artifacts.generate_infographic = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "infographic",
|
||
"orientation": "portrait",
|
||
"detail": "detailed",
|
||
"style": "professional",
|
||
},
|
||
)
|
||
kwargs = mock_client.artifacts.generate_infographic.await_args.kwargs
|
||
assert kwargs["orientation"].name == "PORTRAIT"
|
||
assert kwargs["detail_level"].name == "DETAILED"
|
||
assert kwargs["style"].name == "PROFESSIONAL"
|
||
|
||
|
||
async def test_artifact_generate_mind_map_interactive_default(mcp_call, mock_client) -> None:
|
||
"""Omitted ``map_kind`` defaults to interactive → routes to ``mind_maps.generate``."""
|
||
mock_client.mind_maps.generate = AsyncMock(return_value={"id": "mm1"})
|
||
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "mind-map"})
|
||
mock_client.mind_maps.generate.assert_awaited_once()
|
||
mock_client.artifacts.generate_mind_map.assert_not_called()
|
||
|
||
|
||
async def test_artifact_generate_mind_map_note_backed_routes(mcp_call, mock_client) -> None:
|
||
"""``map_kind=note-backed`` routes to ``artifacts.generate_mind_map`` instead."""
|
||
mock_client.artifacts.generate_mind_map = AsyncMock(return_value={"id": "mm1"})
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "mind-map", "map_kind": "note-backed"},
|
||
)
|
||
mock_client.artifacts.generate_mind_map.assert_awaited_once()
|
||
mock_client.mind_maps.generate.assert_not_called()
|
||
|
||
|
||
async def test_artifact_generate_mind_map_forwards_instructions(mcp_call, mock_client) -> None:
|
||
"""``instructions`` reaches the mind-map client call (the dropped-instructions fix).
|
||
|
||
MCP stores the tool ``instructions`` arg as ``raw_args["description"]``, but the
|
||
mind-map plan reads ``raw_args["instructions"]`` — so MCP also sets that key. Without
|
||
the fix, mind-map instructions were silently discarded.
|
||
"""
|
||
mock_client.mind_maps.generate = AsyncMock(return_value={"id": "mm1"})
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "mind-map",
|
||
"instructions": "focus on the timeline",
|
||
},
|
||
)
|
||
kwargs = mock_client.mind_maps.generate.await_args.kwargs
|
||
assert kwargs["instructions"] == "focus on the timeline"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"artifact_type,opts",
|
||
[
|
||
("video", {"style": "professional"}), # infographic-only value, invalid for video
|
||
("infographic", {"style": "classic"}), # video-only value, invalid for infographic
|
||
],
|
||
ids=["video-bad-style", "infographic-bad-style"],
|
||
)
|
||
async def test_artifact_generate_cross_kind_style_is_validation_error(
|
||
mcp_call, mock_client, artifact_type: str, opts: dict
|
||
) -> None:
|
||
"""A ``style`` value that IS in the global union Literal but invalid for THIS kind
|
||
projects as VALIDATION via the runtime ``_KIND_OPTIONS`` loop.
|
||
|
||
``style`` is a single union Literal (video ∪ infographic), so these values pass
|
||
the schema boundary and must be narrowed per-kind at runtime — proving the
|
||
video/infographic style sets stay enforced separately (they overlap only on
|
||
auto/anime/kawaii).
|
||
"""
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": artifact_type, **opts},
|
||
)
|
||
msg = str(excinfo.value)
|
||
assert "VALIDATION" in msg
|
||
# ...and NOT a boundary rejection: these values are in the global union Literal,
|
||
# so they pass Pydantic and are caught by the runtime per-kind narrowing.
|
||
assert "literal_error" not in msg
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"artifact_type,opts,accepted",
|
||
[
|
||
(
|
||
"report",
|
||
{"report_format": "nonsense"},
|
||
("briefing-doc", "study-guide", "blog-post", "custom"),
|
||
),
|
||
("mind-map", {"map_kind": "bogus"}, ("interactive", "note-backed")),
|
||
("slide-deck", {"deck_format": "nonsense"}, ("detailed", "presenter")),
|
||
# A value outside the GLOBAL union ``style`` Literal rejects at the boundary
|
||
# too (distinct from the cross-kind cases above, which ARE in the union).
|
||
(
|
||
"video",
|
||
{"style": "nonsense"},
|
||
tuple(
|
||
# sorted() so the parametrize id / member order is deterministic
|
||
# across runs (set iteration order varies with hash randomization).
|
||
sorted(
|
||
set(_KIND_OPTIONS["video"]["style"])
|
||
| set(_KIND_OPTIONS["infographic"]["style"])
|
||
)
|
||
),
|
||
),
|
||
],
|
||
ids=["bad-report-format", "bad-map-kind", "bad-deck-format", "out-of-union-style"],
|
||
)
|
||
async def test_artifact_generate_bad_option_value_is_schema_boundary_error(
|
||
mcp_call, mock_client, artifact_type: str, opts: dict, accepted: tuple[str, ...]
|
||
) -> None:
|
||
"""An out-of-enum value for a ``Literal`` option rejects at the schema boundary
|
||
(pydantic ``literal_error``), surfacing the accepted members — NOT the runtime
|
||
``"VALIDATION"`` projection (which only fires for values that pass the boundary,
|
||
i.e. the cross-kind ``style`` cases above).
|
||
|
||
This is the point of the Literal typing: bad values reject earlier (no
|
||
``"VALIDATION"`` substring — same as the unknown-``artifact_type`` case), with
|
||
the schema enum surfaced to the agent. The ``"VALIDATION" not in`` +
|
||
``literal_error in`` assertions are what actually distinguish a boundary
|
||
rejection from the runtime path (both list the accepted members)."""
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": artifact_type, **opts},
|
||
)
|
||
msg = str(excinfo.value)
|
||
assert all(member in msg for member in accepted)
|
||
assert "VALIDATION" not in msg
|
||
assert "literal_error" in msg
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"artifact_type,opts",
|
||
[
|
||
("quiz", {"orientation": "portrait"}), # infographic option on quiz
|
||
("video", {"deck_format": "presenter"}), # slide-deck option on video
|
||
("audio", {"video_format": "brief"}), # video option on audio
|
||
("video", {"map_kind": "interactive"}), # mind-map option on video
|
||
("cinematic-video", {"style": "classic"}), # cinematic-video exposes NO options
|
||
],
|
||
ids=[
|
||
"orientation-on-quiz",
|
||
"deck-on-video",
|
||
"video-on-audio",
|
||
"mapkind-on-video",
|
||
"style-on-cinematic",
|
||
],
|
||
)
|
||
async def test_artifact_generate_wrong_kind_option_is_validation_error(
|
||
mcp_call, mock_client, artifact_type: str, opts: dict
|
||
) -> None:
|
||
"""An option valid for some OTHER kind is rejected, not silently ignored.
|
||
|
||
The neutral core ignores irrelevant extras, so this rejection lives in the MCP tool;
|
||
without it an agent's mis-targeted option would silently no-op.
|
||
"""
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": artifact_type, **opts},
|
||
)
|
||
assert "VALIDATION" in str(excinfo.value)
|
||
|
||
|
||
async def test_artifact_generate_wrong_kind_message_for_optionless_kind(
|
||
mcp_call, mock_client
|
||
) -> None:
|
||
"""A kind with no per-kind options reports that clearly (not ``accepts []``)."""
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "cinematic-video", "style": "classic"},
|
||
)
|
||
assert "no per-kind options" in str(excinfo.value)
|
||
|
||
|
||
async def test_artifact_generate_style_prompt_requires_custom(mcp_call, mock_client) -> None:
|
||
"""``style_prompt`` without ``style=custom`` is rejected (core cross-field rule)."""
|
||
mock_client.artifacts.generate_video = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_generate",
|
||
{"notebook": NB_ID, "artifact_type": "video", "style_prompt": "hand-drawn"},
|
||
)
|
||
assert "VALIDATION" in str(excinfo.value)
|
||
|
||
|
||
def test_kind_options_match_core_maps() -> None:
|
||
"""The MCP per-kind choice tuples are DUPLICATED from the core's private maps (the
|
||
CLI/MCP boundary forbids importing them at runtime). Pin them equal so they can't
|
||
silently drift — the parity tests only exercise valid values and would miss a
|
||
*subset* drift (MCP wrongly rejecting a value the core accepts)."""
|
||
from notebooklm._app import generate_plans as gp
|
||
from notebooklm.mcp.tools.studio import _KIND_OPTIONS
|
||
|
||
assert _KIND_OPTIONS["audio"]["audio_format"] == tuple(gp._AUDIO_FORMAT_MAP)
|
||
assert _KIND_OPTIONS["audio"]["audio_length"] == tuple(gp._AUDIO_LENGTH_MAP)
|
||
assert _KIND_OPTIONS["video"]["video_format"] == tuple(gp._VIDEO_FORMAT_MAP)
|
||
assert _KIND_OPTIONS["video"]["style"] == tuple(gp._VIDEO_STYLE_MAP)
|
||
assert _KIND_OPTIONS["slide-deck"]["deck_format"] == tuple(gp._SLIDE_FORMAT_MAP)
|
||
assert _KIND_OPTIONS["slide-deck"]["deck_length"] == tuple(gp._SLIDE_LENGTH_MAP)
|
||
assert _KIND_OPTIONS["quiz"]["quantity"] == tuple(gp._QUIZ_QUANTITY_MAP)
|
||
assert _KIND_OPTIONS["quiz"]["difficulty"] == tuple(gp._QUIZ_DIFFICULTY_MAP)
|
||
# flashcards reuses the same core maps today; pin independently so a future
|
||
# flashcards-specific map can't drift the MCP set unnoticed.
|
||
assert _KIND_OPTIONS["flashcards"]["quantity"] == tuple(gp._QUIZ_QUANTITY_MAP)
|
||
assert _KIND_OPTIONS["flashcards"]["difficulty"] == tuple(gp._QUIZ_DIFFICULTY_MAP)
|
||
assert _KIND_OPTIONS["infographic"]["orientation"] == tuple(gp._INFOGRAPHIC_ORIENTATION_MAP)
|
||
assert _KIND_OPTIONS["infographic"]["detail"] == tuple(gp._INFOGRAPHIC_DETAIL_MAP)
|
||
assert _KIND_OPTIONS["infographic"]["style"] == tuple(gp._INFOGRAPHIC_STYLE_MAP)
|
||
assert _KIND_OPTIONS["report"]["report_format"] == tuple(gp._REPORT_FORMAT_MAP)
|
||
|
||
|
||
async def test_artifact_generate_exposes_new_option_params(mcp_list_tools) -> None:
|
||
"""The agent-facing tool schema exposes every new per-kind option parameter."""
|
||
tools = await mcp_list_tools()
|
||
schema = next(t for t in tools if t.name == "studio_generate").inputSchema
|
||
properties = schema.get("properties", {})
|
||
for param in (
|
||
"video_format",
|
||
"style",
|
||
"style_prompt",
|
||
"deck_format",
|
||
"deck_length",
|
||
"orientation",
|
||
"detail",
|
||
"map_kind",
|
||
):
|
||
assert param in properties, f"studio_generate must expose {param!r}"
|
||
|
||
|
||
async def test_artifact_generate_option_params_expose_enums(mcp_list_tools) -> None:
|
||
"""Each finite-choice option param is typed ``Literal`` → the tool schema exposes a
|
||
JSON-schema ``enum`` matching ``_KIND_OPTIONS`` (acceptance criterion for #1666).
|
||
|
||
The expected enum is read from ``_KIND_OPTIONS`` (pinned equal to the neutral core
|
||
maps by ``test_kind_options_match_core_maps``), so a core-map change not mirrored
|
||
into BOTH ``_KIND_OPTIONS`` and the signature ``Literal`` fails here. ``style`` is a
|
||
single union Literal, so its enum is the union across video+infographic; ``quantity``
|
||
/``difficulty`` are shared by quiz+flashcards (identical today — assert the union so
|
||
a future flashcards-specific set is still covered)."""
|
||
tools = await mcp_list_tools()
|
||
schema = next(t for t in tools if t.name == "studio_generate").inputSchema
|
||
props = schema.get("properties", {})
|
||
|
||
# Single-kind option params: enum == that kind's choice set.
|
||
single_kind = {
|
||
"report_format": "report",
|
||
"audio_format": "audio",
|
||
"audio_length": "audio",
|
||
"video_format": "video",
|
||
"deck_format": "slide-deck",
|
||
"deck_length": "slide-deck",
|
||
"orientation": "infographic",
|
||
"detail": "infographic",
|
||
"map_kind": "mind-map",
|
||
}
|
||
for param, kind in single_kind.items():
|
||
assert _schema_enum(props[param]) == set(_KIND_OPTIONS[kind][param]), param
|
||
|
||
# quantity/difficulty: shared by quiz+flashcards (union).
|
||
for param in ("quantity", "difficulty"):
|
||
expected = set(_KIND_OPTIONS["quiz"][param]) | set(_KIND_OPTIONS["flashcards"][param])
|
||
assert _schema_enum(props[param]) == expected, param
|
||
|
||
# style: single union Literal across video + infographic.
|
||
expected_style = set(_KIND_OPTIONS["video"]["style"]) | set(
|
||
_KIND_OPTIONS["infographic"]["style"]
|
||
)
|
||
assert _schema_enum(props["style"]) == expected_style
|
||
|
||
|
||
async def test_artifact_generate_free_text_params_have_no_enum(mcp_list_tools) -> None:
|
||
"""``style_prompt`` and ``language`` stay free text — NOT converted to ``Literal``.
|
||
|
||
Uses the same nested-aware ``_schema_enum`` helper so an accidental conversion that
|
||
hid an ``enum`` inside an ``anyOf`` branch would still be caught."""
|
||
tools = await mcp_list_tools()
|
||
schema = next(t for t in tools if t.name == "studio_generate").inputSchema
|
||
props = schema.get("properties", {})
|
||
assert _schema_enum(props["style_prompt"]) is None
|
||
assert _schema_enum(props["language"]) is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_status (stateless poll)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_artifact_status(mcp_call, mock_client) -> None:
|
||
mock_client.artifacts.poll_status = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
|
||
result = await mcp_call("studio_status", {"notebook": NB_ID, "task_id": TASK_ID})
|
||
assert result.structured_content["task_id"] == TASK_ID
|
||
assert result.structured_content["is_complete"] is True
|
||
assert result.structured_content["status"] == GenerationState.COMPLETED.value
|
||
mock_client.artifacts.poll_status.assert_awaited_once_with(NB_ID, TASK_ID)
|
||
|
||
|
||
async def test_artifact_generate_then_status_poll_shape(mcp_call, mock_client) -> None:
|
||
"""The start→status poll loop: generate returns a task_id, status polls it."""
|
||
mock_client.artifacts.generate_audio = AsyncMock(
|
||
return_value=FakeStatus(task_id=TASK_ID, status=GenerationState.PENDING, url=None)
|
||
)
|
||
started = await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "audio"})
|
||
task_id = started.structured_content["task_id"]
|
||
assert task_id == TASK_ID
|
||
|
||
mock_client.artifacts.poll_status = AsyncMock(
|
||
return_value=FakeStatus(task_id=TASK_ID, status=GenerationState.COMPLETED)
|
||
)
|
||
polled = await mcp_call("studio_status", {"notebook": NB_ID, "task_id": task_id})
|
||
assert polled.structured_content["is_complete"] is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_get_prompt
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_artifact_get_prompt(mcp_call, mock_client) -> None:
|
||
"""Happy path: the stored prompt string flows through unchanged."""
|
||
mock_client.artifacts.get_prompt = AsyncMock(return_value="Summarize the intro")
|
||
result = await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": _ART_FULL})
|
||
assert result.structured_content == {
|
||
"notebook_id": NB_ID,
|
||
"artifact_id": _ART_FULL,
|
||
"prompt": "Summarize the intro",
|
||
}
|
||
# Full-UUID ref fast-paths: the resolver never lists artifacts.
|
||
mock_client.artifacts.list.assert_not_called()
|
||
mock_client.artifacts.get_prompt.assert_awaited_once_with(NB_ID, _ART_FULL)
|
||
|
||
|
||
async def test_artifact_get_prompt_none_is_success(mcp_call, mock_client) -> None:
|
||
"""``prompt=None`` (artifact records no prompt) is a valid result, not an error."""
|
||
mock_client.artifacts.get_prompt = AsyncMock(return_value=None)
|
||
result = await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": _ART_FULL})
|
||
assert result.structured_content == {
|
||
"notebook_id": NB_ID,
|
||
"artifact_id": _ART_FULL,
|
||
"prompt": None,
|
||
}
|
||
|
||
|
||
async def test_artifact_get_prompt_resolves_by_title(mcp_call, mock_client) -> None:
|
||
"""A title/prefix ref resolves to the artifact id before the prompt fetch."""
|
||
art = Artifact(
|
||
id=_ART_FULL,
|
||
title="Podcast 1",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[art])
|
||
mock_client.artifacts.get_prompt = AsyncMock(return_value="From the podcast")
|
||
result = await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": "Podcast 1"})
|
||
assert result.structured_content["artifact_id"] == _ART_FULL
|
||
assert result.structured_content["prompt"] == "From the podcast"
|
||
mock_client.artifacts.get_prompt.assert_awaited_once_with(NB_ID, _ART_FULL)
|
||
|
||
|
||
async def test_artifact_get_prompt_unknown_id_projects_tool_error(mcp_call, mock_client) -> None:
|
||
"""An unknown id raises ``ArtifactNotFoundError`` (mapped to NOT_FOUND).
|
||
|
||
``get_prompt`` has no pre-list existence guard — the full-UUID ref reaches the
|
||
client, whose ``get_prompt`` raises for an absent artifact."""
|
||
|
||
def _raise(*_a: Any, **_k: Any) -> Any:
|
||
raise ArtifactNotFoundError(_ART_FULL)
|
||
|
||
mock_client.artifacts.get_prompt = AsyncMock(side_effect=_raise)
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": _ART_FULL})
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_download
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_artifact_download_audio(mcp_call, mock_client, tmp_path) -> None:
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
result = await mcp_call(
|
||
"studio_download", {"notebook": NB_ID, "artifact_type": "audio", "path": out}
|
||
)
|
||
assert result.structured_content["notebook_id"] == NB_ID
|
||
assert result.structured_content["outcome"] == "single_downloaded"
|
||
assert result.structured_content["output_path"] == out
|
||
mock_client.artifacts.download_audio.assert_awaited_once()
|
||
|
||
|
||
async def test_artifact_download_by_artifact_ref_infers_type(
|
||
mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""R3: an ``artifact`` name-or-id ref resolves to its type+id — no ``artifact_type``."""
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
result = await mcp_call(
|
||
"studio_download", {"notebook": NB_ID, "artifact": "Podcast", "path": out}
|
||
)
|
||
assert result.structured_content["outcome"] == "single_downloaded"
|
||
# The audio downloader was selected purely from the resolved artifact's kind,
|
||
# AND the resolved id (not latest-by-type) reached it — guards a regression to
|
||
# latest-by-type that a bare assert_awaited_once() would miss.
|
||
assert result.structured_content["artifact"]["id"] == "art1"
|
||
assert mock_client.artifacts.download_audio.await_args.kwargs["artifact_id"] == "art1"
|
||
|
||
|
||
async def test_artifact_download_ref_and_type_together_is_validation(mcp_call, mock_client) -> None:
|
||
"""Passing both ``artifact`` and ``artifact_type`` is rejected (one addressing mode)."""
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
|
||
with pytest.raises(ToolError) as exc:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact": "Podcast", "artifact_type": "audio"},
|
||
)
|
||
assert "not both" in str(exc.value)
|
||
|
||
|
||
async def test_artifact_download_ref_and_id_together_is_validation(mcp_call, mock_client) -> None:
|
||
"""Passing `artifact` alongside `artifact_id` is rejected (would silently drop the id)."""
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
|
||
with pytest.raises(ToolError) as exc:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact": "Podcast", "artifact_id": "art1"},
|
||
)
|
||
assert "not both" in str(exc.value)
|
||
|
||
|
||
async def test_artifact_download_neither_ref_nor_type_is_validation(mcp_call, mock_client) -> None:
|
||
"""Omitting both ``artifact`` and ``artifact_type`` is rejected."""
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
|
||
with pytest.raises(ToolError) as exc:
|
||
await mcp_call("studio_download", {"notebook": NB_ID})
|
||
assert "artifact_type" in str(exc.value)
|
||
|
||
|
||
async def test_artifact_download_quiz_with_format(mcp_call, mock_client, tmp_path) -> None:
|
||
out = str(tmp_path / "quiz.md")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
|
||
mock_client.artifacts.download_quiz = AsyncMock(return_value=out)
|
||
result = await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "markdown"},
|
||
)
|
||
assert result.structured_content["outcome"] == "single_downloaded"
|
||
# The format kwarg flows through to the bound download coroutine.
|
||
assert mock_client.artifacts.download_quiz.await_args.kwargs.get("output_format") == "markdown"
|
||
|
||
|
||
async def test_artifact_download_unknown_type_is_validation_error(mcp_call, mock_client) -> None:
|
||
"""An unknown download artifact_type is rejected at the Literal schema boundary."""
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download", {"notebook": NB_ID, "artifact_type": "bogus", "path": "/tmp/x"}
|
||
)
|
||
assert "audio" in str(excinfo.value) and "flashcards" in str(excinfo.value)
|
||
|
||
|
||
async def test_artifact_download_bad_format_for_supported_type_is_validation(
|
||
mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""A bad ``format`` for a type that DOES support format projects a Literal schema boundary error."""
|
||
out = str(tmp_path / "quiz.json")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "bogus"},
|
||
)
|
||
assert "validation error" in str(excinfo.value)
|
||
|
||
|
||
async def test_artifact_download_bad_format_cross_validation_is_validation(
|
||
mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""An in-union format value that is invalid for the specific type raises a runtime VALIDATION error."""
|
||
out = str(tmp_path / "quiz.json")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "pdf"},
|
||
)
|
||
assert "VALIDATION" in str(excinfo.value)
|
||
|
||
|
||
async def test_artifact_download_format_for_unsupported_type_is_validation(
|
||
mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""Supplying ``format`` for a type WITHOUT format choices errors (was silently dropped)."""
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "path": out, "output_format": "pdf"},
|
||
)
|
||
assert "VALIDATION" in str(excinfo.value)
|
||
mock_client.artifacts.download_audio.assert_not_called()
|
||
|
||
|
||
async def test_artifact_download_report_markdown_self_documenting(
|
||
mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""report has no format axis → the rejection tells the caller to omit output_format."""
|
||
out = str(tmp_path / "out.md")
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "report",
|
||
"path": out,
|
||
"output_format": "markdown",
|
||
},
|
||
)
|
||
msg = str(excinfo.value)
|
||
assert "supported formats: default only" in msg
|
||
assert "omit output_format" in msg
|
||
|
||
|
||
async def test_artifact_download_audio_pdf_self_documenting(
|
||
mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""audio has no format axis → same self-documenting rejection (pdf is in-union but wrong-type)."""
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact_type": "audio", "path": out, "output_format": "pdf"},
|
||
)
|
||
msg = str(excinfo.value)
|
||
assert "supported formats: default only" in msg
|
||
assert "omit output_format" in msg
|
||
mock_client.artifacts.download_audio.assert_not_called()
|
||
|
||
|
||
async def test_artifact_download_supported_type_invalid_format_lists_choices(
|
||
mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""A type WITH a format axis still lists its allowed values on an invalid choice."""
|
||
out = str(tmp_path / "quiz.json")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "pdf"},
|
||
)
|
||
msg = str(excinfo.value)
|
||
assert "expected one of" in msg
|
||
assert "json" in msg and "markdown" in msg and "html" in msg
|
||
# The no-format-axis wording must NOT leak into a type that has a format axis.
|
||
assert "supported formats: default only" not in msg
|
||
|
||
|
||
async def test_artifact_download_no_artifacts(mcp_call, mock_client, tmp_path) -> None:
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
result = await mcp_call(
|
||
"studio_download", {"notebook": NB_ID, "artifact_type": "audio", "path": out}
|
||
)
|
||
assert result.structured_content["outcome"] == "no_artifacts"
|
||
|
||
|
||
_AUDIO_ARTIFACT_1 = Artifact(
|
||
id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||
title="Podcast 1",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
_AUDIO_ARTIFACT_2 = Artifact(
|
||
id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||
title="Podcast 2",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||
)
|
||
|
||
|
||
async def test_artifact_download_by_full_id(mcp_call, mock_client, tmp_path) -> None:
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
result = await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "audio",
|
||
"path": out,
|
||
"artifact_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||
},
|
||
)
|
||
assert result.structured_content["outcome"] == "single_downloaded"
|
||
assert result.structured_content["artifact"]["id"] == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
mock_client.artifacts.download_audio.assert_awaited_once_with(
|
||
NB_ID, out, artifact_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
)
|
||
|
||
|
||
async def test_artifact_download_by_unique_prefix(mcp_call, mock_client, tmp_path) -> None:
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
result = await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "audio",
|
||
"path": out,
|
||
"artifact_id": "bbbbbbbb-bbbb",
|
||
},
|
||
)
|
||
assert result.structured_content["outcome"] == "single_downloaded"
|
||
assert result.structured_content["artifact"]["id"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||
mock_client.artifacts.download_audio.assert_awaited_once_with(
|
||
NB_ID, out, artifact_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||
)
|
||
|
||
|
||
async def test_artifact_download_by_id_not_found(mcp_call, mock_client, tmp_path) -> None:
|
||
# A not-found ``artifact_id`` (a full UUID absent from the list) is a hard miss,
|
||
# uniform with a not-found / ambiguous prefix — ``_resolve_artifact_id`` raises
|
||
# before the download core's soft ERROR path, mirroring how a bad notebook id
|
||
# surfaces (ToolError / NOT_FOUND).
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "audio",
|
||
"path": out,
|
||
"artifact_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||
},
|
||
)
|
||
assert "not found" in str(excinfo.value)
|
||
mock_client.artifacts.download_audio.assert_not_called()
|
||
|
||
|
||
async def test_artifact_download_by_uppercase_full_id(mcp_call, mock_client, tmp_path) -> None:
|
||
# An uppercase full UUID must still resolve: resolve_ref fast-paths it verbatim,
|
||
# so _resolve_artifact_id case-insensitively matches it back to the list's
|
||
# canonical (lowercase) id that select_artifact compares against.
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
result = await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "audio",
|
||
"path": out,
|
||
"artifact_id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA",
|
||
},
|
||
)
|
||
assert result.structured_content["outcome"] == "single_downloaded"
|
||
assert result.structured_content["artifact"]["id"] == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
mock_client.artifacts.download_audio.assert_awaited_once_with(
|
||
NB_ID, out, artifact_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
)
|
||
|
||
|
||
async def test_artifact_download_by_id_ambiguous_prefix(mcp_call, mock_client, tmp_path) -> None:
|
||
out = str(tmp_path / "out.mp3")
|
||
art_same_1 = Artifact(
|
||
id="cccccccc-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||
title="Podcast A",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
art_same_2 = Artifact(
|
||
id="cccccccc-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||
title="Podcast B",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[art_same_1, art_same_2])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "audio",
|
||
"path": out,
|
||
"artifact_id": "cccccccc",
|
||
},
|
||
)
|
||
assert "Ambiguous ID" in str(excinfo.value)
|
||
mock_client.artifacts.download_audio.assert_not_called()
|
||
|
||
|
||
async def test_artifact_download_latest_preserved(mcp_call, mock_client, tmp_path) -> None:
|
||
out = str(tmp_path / "out.mp3")
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
|
||
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
|
||
result = await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "audio",
|
||
"path": out,
|
||
},
|
||
)
|
||
assert result.structured_content["outcome"] == "single_downloaded"
|
||
assert result.structured_content["artifact"]["id"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||
mock_client.artifacts.download_audio.assert_awaited_once_with(
|
||
NB_ID, out, artifact_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# error projection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_artifact_status_not_found_projects_tool_error(mcp_call, mock_client) -> None:
|
||
def _raise(*_a: Any, **_k: Any) -> Any:
|
||
raise ArtifactNotFoundError(TASK_ID)
|
||
|
||
mock_client.artifacts.poll_status = AsyncMock(side_effect=_raise)
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_status", {"notebook": NB_ID, "task_id": TASK_ID})
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
|
||
|
||
async def test_artifact_list_notebook_not_found_projects_tool_error(mcp_call, mock_client) -> None:
|
||
mock_client.notebooks.list = AsyncMock(return_value=[])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_list", {"notebook": "No Such Notebook"})
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
_ = NotebookNotFoundError # imported for symmetry with sibling suites
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_rename
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ART_FULL = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||
|
||
|
||
async def test_artifact_rename_regular_typed_artifact(mcp_call, mock_client) -> None:
|
||
"""A regular artifact resolves via the typed ``Artifact`` list (NOT a dict) and
|
||
routes to ``artifacts.rename``.
|
||
|
||
Regression guard: the resolver must use attribute access (``a.id`` / ``a.title``)
|
||
on the typed ``Artifact`` objects ``client.artifacts.list`` returns. The earlier
|
||
dict-shaped helper would ``TypeError`` here. Resolving by a hex prefix exercises
|
||
the id/prefix path against the typed list.
|
||
"""
|
||
art = Artifact(
|
||
id=_ART_FULL,
|
||
title="Podcast 1",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.COMPLETED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[art])
|
||
mock_client.mind_maps.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.rename = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_rename",
|
||
{"notebook": NB_ID, "item": "aaaaaaaa-aaaa", "new_title": "Renamed"},
|
||
)
|
||
assert result.structured_content == {
|
||
"status": "renamed",
|
||
"notebook_id": NB_ID,
|
||
"item_id": _ART_FULL,
|
||
"type": "audio",
|
||
"new_title": "Renamed",
|
||
"is_mind_map": False,
|
||
}
|
||
mock_client.artifacts.rename.assert_awaited_once_with(
|
||
NB_ID, _ART_FULL, "Renamed", return_object=False
|
||
)
|
||
mock_client.mind_maps.rename.assert_not_called()
|
||
|
||
|
||
async def test_artifact_rename_interactive_mind_map_by_title(mcp_call, mock_client) -> None:
|
||
"""A mind map resolved by title routes through ``mind_maps.rename`` (is_mind_map true)."""
|
||
mm_id = "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(
|
||
return_value=[FakeArtifact(id=mm_id, title="My Map", kind=ArtifactType.MIND_MAP)]
|
||
)
|
||
mock_client.mind_maps.list = AsyncMock(
|
||
return_value=[FakeMindMap(id=mm_id, kind=MindMapKind.INTERACTIVE)]
|
||
)
|
||
mock_client.mind_maps.rename = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_rename",
|
||
{"notebook": NB_ID, "item": "My Map", "new_title": "Renamed Map"},
|
||
)
|
||
assert result.structured_content["is_mind_map"] is True
|
||
assert result.structured_content["item_id"] == mm_id
|
||
assert result.structured_content["type"] == "mind-map"
|
||
mock_client.mind_maps.rename.assert_awaited_once()
|
||
mock_client.artifacts.rename.assert_not_called()
|
||
|
||
|
||
async def test_artifact_rename_note_backed_mind_map_by_full_uuid(mcp_call, mock_client) -> None:
|
||
"""A note-backed mind map absent from the merged list still renames by full UUID:
|
||
the cross-type resolve misses, the full-UUID carve-out routes to the artifact
|
||
core, whose ``mind_maps.list`` probe finds it → ``mind_maps.rename`` with its kind."""
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.mind_maps.list = AsyncMock(
|
||
return_value=[FakeMindMap(id=_ART_FULL, kind=MindMapKind.NOTE_BACKED)]
|
||
)
|
||
mock_client.mind_maps.rename = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_rename",
|
||
{"notebook": NB_ID, "item": _ART_FULL, "new_title": "Renamed"},
|
||
)
|
||
assert result.structured_content["is_mind_map"] is True
|
||
assert result.structured_content["item_id"] == _ART_FULL
|
||
assert result.structured_content["type"] == "mind-map"
|
||
mock_client.mind_maps.rename.assert_awaited_once()
|
||
assert mock_client.mind_maps.rename.await_args.kwargs["kind"] == MindMapKind.NOTE_BACKED
|
||
|
||
|
||
async def test_artifact_rename_note_backed_mind_map_by_uppercase_full_uuid(
|
||
mcp_call, mock_client
|
||
) -> None:
|
||
"""An UPPERCASE full UUID in the carve-out is normalized to canonical lowercase
|
||
before delegating, so the artifact core's CASE-SENSITIVE ``mind_maps.list`` probe
|
||
still finds the note-backed map. Regression: without the ``item.lower()`` the probe
|
||
would miss and the tool would mislabel it ``type="unknown"`` / ``is_mind_map=False``."""
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.mind_maps.list = AsyncMock(
|
||
return_value=[FakeMindMap(id=_ART_FULL, kind=MindMapKind.NOTE_BACKED)]
|
||
)
|
||
mock_client.mind_maps.rename = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_rename",
|
||
{"notebook": NB_ID, "item": _ART_FULL.upper(), "new_title": "Renamed"},
|
||
)
|
||
assert result.structured_content["is_mind_map"] is True
|
||
assert result.structured_content["type"] == "mind-map"
|
||
# The echoed id is the canonical lowercase form, not the uppercase input.
|
||
assert result.structured_content["item_id"] == _ART_FULL
|
||
mock_client.mind_maps.rename.assert_awaited_once()
|
||
|
||
|
||
async def test_studio_rename_note_routes_to_note_rename(mcp_call, mock_client) -> None:
|
||
"""A resolved NOTE renames via the content-preserving note core (never the
|
||
artifact rename RPC), returning ``type="note"`` / ``is_mind_map=False``."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.notes.get_or_none = AsyncMock(
|
||
return_value=Note(id=_NOTE_ID, notebook_id=NB_ID, title="My Note", content="body")
|
||
)
|
||
mock_client.notes.update = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_rename",
|
||
{"notebook": NB_ID, "item": _NOTE_ID, "new_title": "Renamed Note"},
|
||
)
|
||
assert result.structured_content == {
|
||
"status": "renamed",
|
||
"notebook_id": NB_ID,
|
||
"item_id": _NOTE_ID,
|
||
"type": "note",
|
||
"new_title": "Renamed Note",
|
||
"is_mind_map": False,
|
||
}
|
||
# Content-preserving: the update carries the existing body, only the title changes.
|
||
mock_client.notes.update.assert_awaited_once_with(
|
||
NB_ID, _NOTE_ID, content="body", title="Renamed Note"
|
||
)
|
||
mock_client.artifacts.rename.assert_not_called()
|
||
|
||
|
||
async def test_studio_rename_note_vanished_race_projects_not_found(mcp_call, mock_client) -> None:
|
||
"""A note resolved from the list but gone by the content-preserving ``get``
|
||
(a concurrent delete won the race) projects NOT_FOUND, not a silent success.
|
||
|
||
``execute_note_rename`` returns ``found=False`` when ``get_or_none`` yields a
|
||
non-``Note``; the tool maps that to a ``ToolError``/NOT_FOUND and never writes."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.notes.get_or_none = AsyncMock(return_value=None)
|
||
mock_client.notes.update = AsyncMock()
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_rename",
|
||
{"notebook": NB_ID, "item": _NOTE_ID, "new_title": "Renamed Note"},
|
||
)
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
mock_client.notes.update.assert_not_called()
|
||
mock_client.artifacts.rename.assert_not_called()
|
||
|
||
|
||
async def test_artifact_rename_not_found_projects_tool_error(mcp_call, mock_client) -> None:
|
||
"""A non-UUID (prefix/title) ref that matches no note or artifact projects
|
||
NOT_FOUND (the cross-type resolver raises, ``mcp_errors`` maps to ``ToolError``)."""
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_rename",
|
||
{"notebook": NB_ID, "item": "No Such Artifact", "new_title": "X"},
|
||
)
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
mock_client.artifacts.rename.assert_not_called()
|
||
# The tool layer asserts the wrapped ToolError/NOT_FOUND; the raw
|
||
# NotFoundError is asserted at the resolver layer in test_resolve.py.
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_retry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_artifact_retry_happy_path(mcp_call, mock_client) -> None:
|
||
"""A retry returns the kicked-off ``task_id`` (== artifact id) and new status."""
|
||
mock_client.artifacts.retry_failed = AsyncMock(
|
||
return_value=FakeStatus(task_id=_ART_FULL, status=GenerationState.IN_PROGRESS, url=None)
|
||
)
|
||
result = await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": _ART_FULL})
|
||
assert result.structured_content == {
|
||
"notebook_id": NB_ID,
|
||
"artifact_id": _ART_FULL,
|
||
"task_id": _ART_FULL,
|
||
"status": "in_progress",
|
||
}
|
||
# Full-UUID ref fast-paths: the resolver never lists artifacts.
|
||
mock_client.artifacts.list.assert_not_called()
|
||
mock_client.artifacts.retry_failed.assert_awaited_once_with(NB_ID, _ART_FULL)
|
||
|
||
|
||
async def test_artifact_retry_resolves_by_title(mcp_call, mock_client) -> None:
|
||
"""A title/prefix ref resolves to the artifact id before the retry call."""
|
||
art = Artifact(
|
||
id=_ART_FULL,
|
||
title="Podcast 1",
|
||
_artifact_type=ArtifactTypeCode.AUDIO.value,
|
||
status=int(ArtifactStatus.FAILED),
|
||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[art])
|
||
mock_client.artifacts.retry_failed = AsyncMock(
|
||
return_value=FakeStatus(task_id=_ART_FULL, status=GenerationState.IN_PROGRESS, url=None)
|
||
)
|
||
result = await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": "Podcast 1"})
|
||
assert result.structured_content["artifact_id"] == _ART_FULL
|
||
assert result.structured_content["task_id"] == _ART_FULL
|
||
mock_client.artifacts.retry_failed.assert_awaited_once_with(NB_ID, _ART_FULL)
|
||
|
||
|
||
async def test_artifact_retry_not_found_projects_tool_error(mcp_call, mock_client) -> None:
|
||
"""A prefix/title that matches no artifact projects NOT_FOUND at resolve time."""
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.retry_failed = AsyncMock()
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": "No Such Artifact"})
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
mock_client.artifacts.retry_failed.assert_not_called()
|
||
|
||
|
||
async def test_artifact_retry_refusal_projects_tool_error(mcp_call, mock_client) -> None:
|
||
"""A synchronous client refusal (rate limit / quota) surfaces as a ToolError."""
|
||
|
||
def _raise(*_a: Any, **_k: Any) -> Any:
|
||
raise RateLimitError("quota exceeded")
|
||
|
||
mock_client.artifacts.retry_failed = AsyncMock(side_effect=_raise)
|
||
with pytest.raises(ToolError):
|
||
await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": _ART_FULL})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# studio_delete
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_studio_delete_confirm_false_preview_shape(mcp_call, mock_client) -> None:
|
||
"""``confirm=False`` returns a ``delete_studio_item`` preview and does NOT delete."""
|
||
art = _completed_artifact(_ART_FULL, "Podcast 1")
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[art])
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": "aaaaaaaa-aaaa"},
|
||
)
|
||
assert result.structured_content["status"] == "needs_confirmation"
|
||
preview = result.structured_content["preview"]
|
||
assert preview == {
|
||
"action": "delete_studio_item",
|
||
"notebook_id": NB_ID,
|
||
"item_id": _ART_FULL,
|
||
"type": "audio",
|
||
"title": "Podcast 1",
|
||
}
|
||
mock_client.artifacts.delete.assert_not_called()
|
||
mock_client.notes.delete.assert_not_called()
|
||
|
||
|
||
async def test_studio_delete_note_routes_to_note_delete(mcp_call, mock_client) -> None:
|
||
"""A resolved NOTE deletes via the note core (never the artifact delete RPC)."""
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.notes.delete = AsyncMock()
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": _NOTE_ID, "confirm": True},
|
||
)
|
||
assert result.structured_content == {
|
||
"status": "deleted",
|
||
"notebook_id": NB_ID,
|
||
"item_id": _NOTE_ID,
|
||
"type": "note",
|
||
"was_note_backed": False,
|
||
}
|
||
mock_client.notes.delete.assert_awaited_once_with(NB_ID, _NOTE_ID)
|
||
mock_client.artifacts.delete.assert_not_called()
|
||
|
||
|
||
async def test_studio_delete_artifact_routes_to_artifact_delete(mcp_call, mock_client) -> None:
|
||
"""A resolved ARTIFACT deletes via the artifact delete core (was_note_backed false)."""
|
||
art = _completed_artifact(_ART_FULL, "Podcast 1")
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[art])
|
||
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[])
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": "aaaaaaaa-aaaa", "confirm": True},
|
||
)
|
||
assert result.structured_content == {
|
||
"status": "deleted",
|
||
"notebook_id": NB_ID,
|
||
"item_id": _ART_FULL,
|
||
"type": "audio",
|
||
"was_note_backed": False,
|
||
}
|
||
mock_client.artifacts.delete.assert_awaited_once_with(NB_ID, _ART_FULL)
|
||
mock_client.notes.delete.assert_not_called()
|
||
|
||
|
||
async def test_studio_delete_note_backed_mind_map_by_title(mcp_call, mock_client) -> None:
|
||
"""A note-backed mind map resolved by title routes through the artifact delete
|
||
core, which clears it via ``notes.delete`` (was_note_backed true)."""
|
||
mm_id = "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(
|
||
return_value=[FakeArtifact(id=mm_id, title="My Map", kind=ArtifactType.MIND_MAP)]
|
||
)
|
||
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[FakeMindMap(id=mm_id)])
|
||
mock_client.notes.delete = AsyncMock()
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": "My Map", "confirm": True},
|
||
)
|
||
assert result.structured_content["was_note_backed"] is True
|
||
assert result.structured_content["item_id"] == mm_id
|
||
assert result.structured_content["type"] == "mind-map"
|
||
mock_client.notes.delete.assert_awaited_once_with(NB_ID, mm_id)
|
||
mock_client.artifacts.delete.assert_not_called()
|
||
|
||
|
||
async def test_studio_delete_note_backed_mind_map_by_uppercase_full_uuid(
|
||
mcp_call, mock_client
|
||
) -> None:
|
||
"""An UPPERCASE full UUID in the delete carve-out is normalized to canonical
|
||
lowercase before delegating, so the core's CASE-SENSITIVE ``list_note_backed``
|
||
probe still finds the note-backed map and clears it via ``notes.delete``.
|
||
Mirrors the studio_rename uppercase carve-out fix; regression: without the
|
||
``item.lower()`` the probe would miss → ``was_note_backed=False`` / ``unknown``."""
|
||
mm_id = "dddddddd-dddd-dddd-dddd-dddddddddddd"
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[FakeMindMap(id=mm_id)])
|
||
mock_client.notes.delete = AsyncMock()
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": mm_id.upper(), "confirm": True},
|
||
)
|
||
assert result.structured_content["was_note_backed"] is True
|
||
assert result.structured_content["type"] == "mind-map"
|
||
# The echoed id + the core call use the canonical lowercase form, not the input.
|
||
assert result.structured_content["item_id"] == mm_id
|
||
mock_client.notes.delete.assert_awaited_once_with(NB_ID, mm_id)
|
||
|
||
|
||
async def test_artifact_delete_absent_full_uuid_is_idempotent(mcp_call, mock_client) -> None:
|
||
"""Deleting an already-absent full UUID is a no-error no-op: the merged list
|
||
holds neither a note nor an artifact for it, so the full-UUID carve-out routes
|
||
to ``artifacts.delete`` (idempotent on missing) without raising."""
|
||
absent = "cccccccc-cccc-cccc-cccc-cccccccccccc"
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[])
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": absent, "confirm": True},
|
||
)
|
||
assert result.structured_content == {
|
||
"status": "deleted",
|
||
"notebook_id": NB_ID,
|
||
"item_id": absent,
|
||
"type": "unknown",
|
||
"was_note_backed": False,
|
||
}
|
||
mock_client.artifacts.delete.assert_awaited_once_with(NB_ID, absent)
|
||
mock_client.notes.delete.assert_not_called()
|
||
|
||
|
||
async def test_studio_delete_full_uuid_never_matches_a_note_title(mcp_call, mock_client) -> None:
|
||
"""A full UUID is an id-only ref: it must NOT match a note whose *title* happens
|
||
to be that UUID. Otherwise the absent-full-UUID idempotent no-op would instead
|
||
delete the title-collision note (data loss). It routes to the artifact path."""
|
||
uuid_titled_note = "cccccccc-cccc-cccc-cccc-cccccccccccc"
|
||
mock_client.notes.list = AsyncMock(
|
||
return_value=[FakeNote(id=_NOTE_ID, title=uuid_titled_note, content="body")]
|
||
)
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[])
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
result = await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": uuid_titled_note, "confirm": True},
|
||
)
|
||
# Routed to the artifact path (idempotent no-op), NOT the note delete.
|
||
assert result.structured_content["type"] == "unknown"
|
||
mock_client.notes.delete.assert_not_called()
|
||
mock_client.artifacts.delete.assert_awaited_once_with(NB_ID, uuid_titled_note)
|
||
|
||
|
||
async def test_artifact_delete_absent_prefix_projects_tool_error(mcp_call, mock_client) -> None:
|
||
"""An absent prefix/title raises NOT_FOUND (never reaching a delete core) —
|
||
distinct from the idempotent absent-full-UUID case above."""
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.delete = AsyncMock()
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_delete",
|
||
{"notebook": NB_ID, "item": "No Such Artifact", "confirm": True},
|
||
)
|
||
assert "NOT_FOUND" in str(excinfo.value)
|
||
mock_client.artifacts.delete.assert_not_called()
|
||
mock_client.notes.delete.assert_not_called()
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Strict IDs-only mode (NOTEBOOKLM_MCP_STRICT_IDS=1) — issue #1808
|
||
#
|
||
# The studio `item` resolver (resolve_studio_item) and studio_download's explicit
|
||
# `artifact_id` are additional artifact/note reference paths that must honor strict
|
||
# mode too — a title/prefix is rejected BEFORE the merged studio list is fetched.
|
||
# --------------------------------------------------------------------------- #
|
||
@pytest.fixture
|
||
def _strict_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setenv("NOTEBOOKLM_MCP_STRICT_IDS", "1")
|
||
|
||
|
||
async def test_strict_studio_list_item_title_rejected_without_listing(
|
||
_strict_ids, mcp_call, mock_client
|
||
) -> None:
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_list", {"notebook": NB_ID, "item": "My Podcast"})
|
||
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
|
||
mock_client.notes.list.assert_not_called()
|
||
mock_client.artifacts.list.assert_not_called()
|
||
|
||
|
||
async def test_strict_studio_delete_title_rejected_without_listing(
|
||
_strict_ids, mcp_call, mock_client
|
||
) -> None:
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_delete", {"notebook": NB_ID, "item": "My Podcast", "confirm": True})
|
||
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
|
||
mock_client.notes.list.assert_not_called()
|
||
mock_client.artifacts.list.assert_not_called()
|
||
|
||
|
||
async def test_strict_studio_rename_title_rejected_without_listing(
|
||
_strict_ids, mcp_call, mock_client
|
||
) -> None:
|
||
mock_client.notes.list = AsyncMock(return_value=[])
|
||
mock_client.artifacts.list = AsyncMock(return_value=[])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call("studio_rename", {"notebook": NB_ID, "item": "My Podcast", "new_title": "X"})
|
||
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
|
||
mock_client.notes.list.assert_not_called()
|
||
mock_client.artifacts.list.assert_not_called()
|
||
|
||
|
||
async def test_strict_studio_download_prefix_artifact_id_rejected(
|
||
_strict_ids, mcp_call, mock_client, tmp_path
|
||
) -> None:
|
||
"""A short `artifact_id` prefix on the explicit path is rejected before listing."""
|
||
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
|
||
with pytest.raises(ToolError) as excinfo:
|
||
await mcp_call(
|
||
"studio_download",
|
||
{
|
||
"notebook": NB_ID,
|
||
"artifact_type": "audio",
|
||
"artifact_id": "abc123",
|
||
"path": str(tmp_path / "o.mp3"),
|
||
},
|
||
)
|
||
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
|
||
mock_client.artifacts.list.assert_not_called()
|