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
2429 lines
105 KiB
Python
2429 lines
105 KiB
Python
"""Unit tests for the source 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`` ``source_add`` dispatch, the confirm
|
|
preview-then-delete flow, and error projection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
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.sources import SourceType # noqa: E402 - after importorskip guard
|
|
from notebooklm.exceptions import ( # noqa: E402 - after importorskip guard
|
|
NetworkError,
|
|
RPCError,
|
|
SourceNotFoundError,
|
|
SourceProcessingError,
|
|
SourceTimeoutError,
|
|
)
|
|
from notebooklm.mcp._errors import tool_error_payload # noqa: E402 - after importorskip guard
|
|
from notebooklm.mcp.tools._content_sanity import ( # noqa: E402 - after importorskip guard
|
|
_THIN_SOURCE_CHAR_THRESHOLD,
|
|
)
|
|
from notebooklm.rpc.types import SourceStatus # noqa: E402 - after importorskip guard
|
|
from notebooklm.types import Label, Source # noqa: E402 - after importorskip guard
|
|
|
|
from .conftest import AsyncMock # noqa: E402 - after importorskip guard
|
|
|
|
|
|
@dataclass
|
|
class FakeSource:
|
|
id: str
|
|
title: str | None = None
|
|
|
|
# ``kind``/``status`` are properties (not fields) → mirror real Source: dropped
|
|
# by to_jsonable but read by the tool's _source_view to add string labels.
|
|
# NOTE: ``kind`` is hardcoded WEB_PAGE, so any source_wait test that lands this in
|
|
# the ``ready`` bucket triggers the #1698 thin-content fetch — mock
|
|
# ``client.sources.get_fulltext`` (ample content) or a swallowed error yields a
|
|
# green-for-the-wrong-reason pass. Use FakeReadyTextSource for a non-web-page READY.
|
|
@property
|
|
def is_ready(self) -> bool:
|
|
return True
|
|
|
|
@property
|
|
def is_error(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def kind(self) -> SourceType:
|
|
return SourceType.WEB_PAGE
|
|
|
|
@property
|
|
def status(self) -> SourceStatus:
|
|
return SourceStatus.READY
|
|
|
|
|
|
@dataclass
|
|
class FakeNotReadySource:
|
|
"""A source that exists but is still processing (``is_ready`` False)."""
|
|
|
|
id: str
|
|
title: str | None = None
|
|
|
|
@property
|
|
def is_ready(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def is_error(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def kind(self) -> SourceType:
|
|
return SourceType.PDF
|
|
|
|
@property
|
|
def status(self) -> SourceStatus:
|
|
return SourceStatus.PROCESSING
|
|
|
|
|
|
@dataclass
|
|
class FakeFailedSource:
|
|
"""A source whose import failed (status ERROR) — the ghost row left by a
|
|
broken ``source_add``. Exercises the synchronous failure-signal path."""
|
|
|
|
id: str
|
|
title: str | None = None
|
|
|
|
@property
|
|
def is_ready(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def is_error(self) -> bool:
|
|
return True
|
|
|
|
@property
|
|
def kind(self) -> SourceType:
|
|
return SourceType.WEB_PAGE
|
|
|
|
@property
|
|
def status(self) -> SourceStatus:
|
|
return SourceStatus.ERROR
|
|
|
|
|
|
@dataclass
|
|
class FakeReadyTextSource:
|
|
"""A READY non-web-page source (pasted text). ``FakeSource`` hardcodes
|
|
``kind=WEB_PAGE``; this fake exists so the source_wait thin-content sanity
|
|
check can prove it NEVER flags (or even fetches) a non-web-page source —
|
|
legitimately short pasted text / transcripts must not be warned about."""
|
|
|
|
id: str
|
|
title: str | None = None
|
|
|
|
@property
|
|
def is_ready(self) -> bool:
|
|
return True
|
|
|
|
@property
|
|
def is_error(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def kind(self) -> SourceType:
|
|
return SourceType.PASTED_TEXT
|
|
|
|
@property
|
|
def status(self) -> SourceStatus:
|
|
return SourceStatus.READY
|
|
|
|
|
|
@dataclass
|
|
class FakeFulltext:
|
|
"""Stand-in for ``SourceFulltext`` (what ``client.sources.get_fulltext`` returns)."""
|
|
|
|
content: str = ""
|
|
char_count: int = 0
|
|
source_id: str = ""
|
|
title: str = ""
|
|
|
|
|
|
NB_ID = "11111111-1111-1111-1111-111111111111"
|
|
SRC_ID = "33333333-3333-3333-3333-333333333333"
|
|
SRC2_ID = "44444444-4444-4444-4444-444444444444"
|
|
|
|
|
|
async def test_source_list(mcp_call, mock_client) -> None:
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Doc")])
|
|
result = await mcp_call("source_list", {"notebook": NB_ID})
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"sources": [{"id": SRC_ID, "title": "Doc", "kind": "web_page", "status_label": "ready"}],
|
|
"total": 1,
|
|
"offset": 0,
|
|
"has_more": False,
|
|
}
|
|
mock_client.sources.list.assert_awaited_once_with(NB_ID)
|
|
|
|
|
|
async def test_source_list_status_filter(mcp_call, mock_client) -> None:
|
|
"""``status`` narrows the list to sources whose ``status_label`` matches."""
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[
|
|
FakeSource(id=SRC_ID, title="Ready Doc"),
|
|
FakeFailedSource(id=SRC2_ID, title="Broken Import"),
|
|
]
|
|
)
|
|
result = await mcp_call("source_list", {"notebook": NB_ID, "status": "error"})
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"sources": [
|
|
{
|
|
"id": SRC2_ID,
|
|
"title": "Broken Import",
|
|
"kind": "web_page",
|
|
"status_label": "error",
|
|
}
|
|
],
|
|
"total": 1,
|
|
"offset": 0,
|
|
"has_more": False,
|
|
}
|
|
|
|
|
|
async def test_source_list_status_filter_no_match(mcp_call, mock_client) -> None:
|
|
"""A filter matching nothing yields an empty list (notebook_id still present)."""
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Ready Doc")])
|
|
result = await mcp_call("source_list", {"notebook": NB_ID, "status": "error"})
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"sources": [],
|
|
"total": 0,
|
|
"offset": 0,
|
|
"has_more": False,
|
|
}
|
|
|
|
|
|
async def test_source_list_invalid_status_filter_rejected(mcp_call, mock_client) -> None:
|
|
"""An out-of-enum ``status`` is rejected at the schema boundary (Literal).
|
|
|
|
Pydantic's exact wording varies by version, so assert loosely that the allowed
|
|
labels surface in the error — matching ``test_source_read_invalid_format``.
|
|
"""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_list", {"notebook": NB_ID, "status": "failed"})
|
|
msg = str(excinfo.value).lower()
|
|
assert "error" in msg and "ready" in msg
|
|
|
|
|
|
async def test_source_list_status_filter_enum_parity(mcp_list_tools) -> None:
|
|
"""The ``status`` filter's accepted values are exactly the lower-cased
|
|
``SourceStatus`` member names (the same vocabulary ``status_label`` emits).
|
|
|
|
This pins the hand-written ``Literal`` to the enum so a future ``SourceStatus``
|
|
member can't silently become unfilterable: adding one without extending the
|
|
``Literal`` trips this guard.
|
|
"""
|
|
tools = {t.name: t for t in await mcp_list_tools()}
|
|
status_schema = tools["source_list"].inputSchema["properties"]["status"]
|
|
# ``status: Literal[...] | None`` serializes as an ``anyOf`` of {enum} + {null}.
|
|
# Pull the one branch that carries the enum list.
|
|
enum_values = next(branch["enum"] for branch in status_schema["anyOf"] if "enum" in branch)
|
|
assert set(enum_values) == {s.name.lower() for s in SourceStatus}
|
|
|
|
|
|
async def test_source_list_status_filter_non_ready_labels(mcp_call, mock_client) -> None:
|
|
"""Non-ready labels filter too: a ``processing`` / ``error`` source is returned
|
|
when filtering by its own label (the ``ready`` case is covered above; the full
|
|
label set is pinned to the enum by ``test_source_list_status_filter_enum_parity``)."""
|
|
for fake in (FakeNotReadySource(id=SRC_ID, title="P"), FakeFailedSource(id=SRC_ID, title="E")):
|
|
mock_client.sources.list = AsyncMock(return_value=[fake])
|
|
label = fake.status.name.lower()
|
|
result = await mcp_call("source_list", {"notebook": NB_ID, "status": label})
|
|
sources = result.structured_content["sources"]
|
|
assert [s["status_label"] for s in sources] == [label]
|
|
|
|
|
|
async def test_source_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.sources.list = AsyncMock(return_value=[])
|
|
result = await mcp_call("source_list", {"notebook": "My Notebook"})
|
|
assert result.structured_content["notebook_id"] == NB_ID
|
|
mock_client.sources.list.assert_awaited_with(NB_ID)
|
|
|
|
|
|
async def test_source_list_compact(mcp_call, mock_client) -> None:
|
|
"""``detail="compact"`` projects each source to a 5-field roster row.
|
|
|
|
Uses a real ``Source`` so ``created_at`` (dropped by the minimal fakes)
|
|
actually serializes: the row is exactly ``{id, title, kind, status_label,
|
|
created_at}`` — no ``url`` / raw ``status`` / ``_type_code`` — for a low-token
|
|
listing with no extra read.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
from notebooklm.types import Source
|
|
|
|
src = Source(
|
|
id=SRC_ID,
|
|
title="Doc",
|
|
_type_code=3, # pdf
|
|
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
|
status=SourceStatus.READY,
|
|
)
|
|
mock_client.sources.list = AsyncMock(return_value=[src])
|
|
result = await mcp_call("source_list", {"notebook": NB_ID, "detail": "compact"})
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"sources": [
|
|
{
|
|
"id": SRC_ID,
|
|
"title": "Doc",
|
|
"kind": "pdf",
|
|
"status_label": "ready",
|
|
"created_at": "2024-01-01T00:00:00+00:00",
|
|
}
|
|
],
|
|
"total": 1,
|
|
"offset": 0,
|
|
"has_more": False,
|
|
}
|
|
|
|
|
|
async def test_source_list_compact_composes_with_status_filter(mcp_call, mock_client) -> None:
|
|
"""``detail="compact"`` still honors the ``status`` filter (shaping is orthogonal)."""
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[
|
|
FakeSource(id=SRC_ID, title="Ready Doc"),
|
|
FakeFailedSource(id=SRC2_ID, title="Broken Import"),
|
|
]
|
|
)
|
|
result = await mcp_call(
|
|
"source_list", {"notebook": NB_ID, "detail": "compact", "status": "error"}
|
|
)
|
|
rows = result.structured_content["sources"]
|
|
assert [r["id"] for r in rows] == [SRC2_ID]
|
|
assert set(rows[0]) == {"id", "title", "kind", "status_label", "created_at"}
|
|
assert rows[0]["status_label"] == "error"
|
|
|
|
|
|
async def test_source_list_compact_null_created_at(mcp_call, mock_client) -> None:
|
|
"""A still-processing source (no decoded ``created_at``) serializes ``created_at``
|
|
to ``None`` in the compact row — the field is always present, never omitted."""
|
|
from notebooklm.types import Source
|
|
|
|
src = Source(
|
|
id=SRC_ID, title="Importing", _type_code=3, created_at=None, status=SourceStatus.PROCESSING
|
|
)
|
|
mock_client.sources.list = AsyncMock(return_value=[src])
|
|
result = await mcp_call("source_list", {"notebook": NB_ID, "detail": "compact"})
|
|
row = result.structured_content["sources"][0]
|
|
assert set(row) == {"id", "title", "kind", "status_label", "created_at"}
|
|
assert row["created_at"] is None
|
|
assert row["status_label"] == "processing"
|
|
|
|
|
|
async def test_source_list_default_is_full_unchanged(mcp_call, mock_client) -> None:
|
|
"""The default call (no ``detail``) is byte-identical to before (full projection)."""
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Doc")])
|
|
result = await mcp_call("source_list", {"notebook": NB_ID})
|
|
assert result.structured_content["sources"] == [
|
|
{"id": SRC_ID, "title": "Doc", "kind": "web_page", "status_label": "ready"}
|
|
]
|
|
|
|
|
|
async def test_source_read(mcp_call, mock_client) -> None:
|
|
"""Returns the source metadata AND its full text content + char_count."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="hello world", char_count=11)
|
|
)
|
|
result = await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"source_id": SRC_ID,
|
|
"source": {
|
|
"id": SRC_ID,
|
|
"title": "Doc",
|
|
"kind": "web_page",
|
|
"status_label": "ready",
|
|
},
|
|
"content": "hello world",
|
|
"char_count": 11,
|
|
"truncated": False,
|
|
"output_format": "text",
|
|
}
|
|
mock_client.sources.get_or_none.assert_awaited_once_with(NB_ID, SRC_ID)
|
|
mock_client.sources.get_fulltext.assert_awaited_once_with(NB_ID, SRC_ID, output_format="text")
|
|
|
|
|
|
async def test_source_read_windowing(mcp_call, mock_client) -> None:
|
|
"""offset/max_chars window the body; char_count stays full; truncated reflects it."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="abcdefghij", char_count=10)
|
|
)
|
|
result = await mcp_call(
|
|
"source_read",
|
|
{"notebook": NB_ID, "source": SRC_ID, "offset": 2, "max_chars": 3},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["content"] == "cde"
|
|
assert sc["char_count"] == 10 # full length, not the window
|
|
assert sc["truncated"] is True
|
|
# A window covering the remainder is not truncated.
|
|
result2 = await mcp_call(
|
|
"source_read",
|
|
{"notebook": NB_ID, "source": SRC_ID, "offset": 7, "max_chars": 100},
|
|
)
|
|
assert result2.structured_content["content"] == "hij"
|
|
assert result2.structured_content["truncated"] is False
|
|
|
|
|
|
async def test_source_read_default_cap(mcp_call, mock_client) -> None:
|
|
"""Omitting max_chars caps the body at the default (10k), not the full text."""
|
|
big = "x" * 25_000
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content=big, char_count=len(big))
|
|
)
|
|
result = await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
assert len(sc["content"]) == 10_000 # bounded by the default cap
|
|
assert sc["char_count"] == 25_000 # full length still reported
|
|
assert sc["truncated"] is True
|
|
|
|
|
|
async def test_source_read_negative_window_is_validation_error(mcp_call, mock_client) -> None:
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_read",
|
|
{"notebook": NB_ID, "source": SRC_ID, "max_chars": -1},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_read_offset_past_end_returns_null(mcp_call, mock_client) -> None:
|
|
"""An offset past the body end yields an empty slice → normalized to null."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="abc", char_count=3)
|
|
)
|
|
result = await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID, "offset": 99})
|
|
assert result.structured_content["content"] is None
|
|
|
|
|
|
async def test_source_wait_negative_timeout_is_validation_error(mcp_call, mock_client) -> None:
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "timeout": -1.0})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_wait_zero_interval_is_validation_error(mcp_call, mock_client) -> None:
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "interval": 0.0})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_wait_all_over_cap_is_rejected(mcp_call, mock_client) -> None:
|
|
"""The omitted-``sources`` wait-all path is capped at MAX_WAIT_SOURCE_IDS via the
|
|
shared fan-out backstop (parity with REST, which rejected this all along — #1871).
|
|
Without the backstop MCP silently waited on every source; REST returned 400."""
|
|
from notebooklm._app.source_wait import MAX_WAIT_SOURCE_IDS
|
|
|
|
over = MAX_WAIT_SOURCE_IDS + 1
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[FakeSource(id=f"{i:08d}-0000-0000-0000-000000000000") for i in range(over)]
|
|
)
|
|
mock_client.sources.wait_all_until_ready = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
# Listed to learn the count, but no poll was started past the cap.
|
|
mock_client.sources.wait_all_until_ready.assert_not_awaited()
|
|
|
|
|
|
def test_drive_mime_choices_match_core_map() -> None:
|
|
"""The MCP drive-MIME tuple is duplicated from the core's ``_DRIVE_MIME_MAP``;
|
|
pin them equal so a new core MIME type can't silently lag the MCP validation."""
|
|
from notebooklm._app import source_mutations as mut_core
|
|
from notebooklm.mcp.tools.sources import _DRIVE_MIME_CHOICES
|
|
|
|
assert set(_DRIVE_MIME_CHOICES) == set(mut_core._DRIVE_MIME_MAP)
|
|
|
|
|
|
async def test_source_read_markdown_format(mcp_call, mock_client) -> None:
|
|
"""``output_format='markdown'`` is forwarded to the fulltext fetch."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="# Heading", char_count=9)
|
|
)
|
|
result = await mcp_call(
|
|
"source_read",
|
|
{"notebook": NB_ID, "source": SRC_ID, "output_format": "markdown"},
|
|
)
|
|
assert result.structured_content["content"] == "# Heading"
|
|
assert result.structured_content["output_format"] == "markdown"
|
|
mock_client.sources.get_fulltext.assert_awaited_once_with(
|
|
NB_ID, SRC_ID, output_format="markdown"
|
|
)
|
|
|
|
|
|
async def test_source_read_invalid_format_rejected(mcp_call, mock_client) -> None:
|
|
"""An out-of-enum ``output_format`` is rejected at the schema boundary.
|
|
|
|
Typing the param as ``Literal["text", "markdown"]`` makes FastMCP/Pydantic emit
|
|
a JSON-schema enum and reject anything else before the tool body runs — agents
|
|
see the allowed values in the tool schema.
|
|
"""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_read",
|
|
{"notebook": NB_ID, "source": SRC_ID, "output_format": "pdf"},
|
|
)
|
|
msg = str(excinfo.value).lower()
|
|
assert "text" in msg and "markdown" in msg
|
|
|
|
|
|
async def test_source_read_markdown_missing_extra_is_config_error(mcp_call, mock_client) -> None:
|
|
"""``output_format='markdown'`` without the ``markdownify`` extra surfaces a CONFIG
|
|
error (with the install hint), not a bug-class UNEXPECTED."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
side_effect=ImportError(
|
|
"The 'markdown' format requires the 'markdownify' package. "
|
|
"Install it with: pip install 'notebooklm-py[markdown]'"
|
|
)
|
|
)
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_read",
|
|
{"notebook": NB_ID, "source": SRC_ID, "output_format": "markdown"},
|
|
)
|
|
msg = str(excinfo.value)
|
|
assert "CONFIG" in msg
|
|
assert "markdownify" in msg # the actionable install hint survives
|
|
|
|
|
|
async def test_source_read_text_import_error_not_remapped(mcp_call, mock_client) -> None:
|
|
"""An ImportError on the TEXT path is genuinely unexpected — it must NOT be
|
|
relabeled CONFIG (the remap is restricted to the markdown case)."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(side_effect=ImportError("unrelated boom"))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert "CONFIG" not in str(excinfo.value)
|
|
|
|
|
|
async def test_source_read_not_ready_returns_null_without_fetch(mcp_call, mock_client) -> None:
|
|
"""A still-processing source returns metadata + content=null and does NOT fetch
|
|
the body (gating on status avoids both a wasted RPC and masking a genuine
|
|
not-found)."""
|
|
mock_client.sources.get_or_none = AsyncMock(
|
|
return_value=FakeNotReadySource(id=SRC_ID, title="Doc")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(return_value=FakeFulltext(content="x"))
|
|
result = await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content["source"] == {
|
|
"id": SRC_ID,
|
|
"title": "Doc",
|
|
"kind": "pdf",
|
|
"status_label": "processing",
|
|
}
|
|
assert result.structured_content["content"] is None
|
|
assert result.structured_content["char_count"] == 0
|
|
assert result.structured_content["output_format"] == "text"
|
|
mock_client.sources.get_fulltext.assert_not_called()
|
|
|
|
|
|
async def test_source_read_ready_but_gone_propagates_not_found(mcp_call, mock_client) -> None:
|
|
"""A READY source whose fulltext fetch raises NOT_FOUND (e.g. deleted between the
|
|
metadata and body calls) propagates as NOT_FOUND — it is NOT masked as
|
|
content=null."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(side_effect=SourceNotFoundError(SRC_ID))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert "NOT_FOUND" in str(excinfo.value) or "not found" in str(excinfo.value).lower()
|
|
|
|
|
|
async def test_source_read_empty_body_normalized_to_null(mcp_call, mock_client) -> None:
|
|
"""An empty extracted body (``""``) is surfaced as ``null``, not an empty string."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Doc"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="", char_count=0)
|
|
)
|
|
result = await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content["content"] is None
|
|
|
|
|
|
async def test_source_read_resolves_source_by_name(mcp_call, mock_client) -> None:
|
|
"""A non-id ``source`` ref resolves by exact title within the notebook."""
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Paper")])
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Paper"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="body", char_count=4)
|
|
)
|
|
result = await mcp_call("source_read", {"notebook": NB_ID, "source": "Paper"})
|
|
assert result.structured_content["source_id"] == SRC_ID
|
|
mock_client.sources.get_or_none.assert_awaited_once_with(NB_ID, SRC_ID)
|
|
|
|
|
|
@dataclass
|
|
class FakeGuide:
|
|
"""Stand-in for ``SourceGuide`` (what ``client.sources.get_guide`` returns).
|
|
|
|
``execute_source_guide`` reads ``.summary`` / ``.keywords`` by attribute, so a
|
|
plain stub suffices — no need to build the real frozen dataclass.
|
|
"""
|
|
|
|
summary: str = ""
|
|
keywords: tuple[str, ...] = ()
|
|
|
|
|
|
async def test_source_read_summary(mcp_call, mock_client) -> None:
|
|
"""Returns the AI summary + keywords (keywords as a JSON list)."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Paper"))
|
|
mock_client.sources.get_guide = AsyncMock(
|
|
return_value=FakeGuide(summary="A short overview.", keywords=("alpha", "beta"))
|
|
)
|
|
result = await mcp_call(
|
|
"source_read", {"detail": "summary", "notebook": NB_ID, "source": SRC_ID}
|
|
)
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"source_id": SRC_ID,
|
|
"summary": "A short overview.",
|
|
"keywords": ["alpha", "beta"],
|
|
}
|
|
mock_client.sources.get_guide.assert_awaited_once_with(NB_ID, SRC_ID)
|
|
|
|
|
|
async def test_source_read_summary_resolves_source_by_name(mcp_call, mock_client) -> None:
|
|
"""A non-id ``source`` ref resolves by exact title within the notebook."""
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Paper")])
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Paper"))
|
|
mock_client.sources.get_guide = AsyncMock(
|
|
return_value=FakeGuide(summary="Body summary.", keywords=("topic",))
|
|
)
|
|
result = await mcp_call(
|
|
"source_read", {"detail": "summary", "notebook": NB_ID, "source": "Paper"}
|
|
)
|
|
assert result.structured_content["source_id"] == SRC_ID
|
|
mock_client.sources.get_guide.assert_awaited_once_with(NB_ID, SRC_ID)
|
|
|
|
|
|
async def test_source_read_summary_existing_source_empty_guide_is_success(
|
|
mcp_call, mock_client
|
|
) -> None:
|
|
"""A real source with no guide yet (still processing) returns empty
|
|
summary/keywords — a valid state, NOT NOT_FOUND (distinct from a missing id)."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Paper"))
|
|
mock_client.sources.get_guide = AsyncMock(return_value=FakeGuide(summary="", keywords=()))
|
|
result = await mcp_call(
|
|
"source_read", {"detail": "summary", "notebook": NB_ID, "source": SRC_ID}
|
|
)
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"source_id": SRC_ID,
|
|
"summary": "",
|
|
"keywords": [],
|
|
}
|
|
|
|
|
|
async def test_source_read_summary_missing_full_uuid_is_not_found(mcp_call, mock_client) -> None:
|
|
"""A full-UUID ref skips list resolution, so a non-existent source reaches the
|
|
existence guard → NOT_FOUND (not a misleading empty-guide success), and the
|
|
guide RPC is never attempted."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=None)
|
|
mock_client.sources.get_guide = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_read", {"detail": "summary", "notebook": NB_ID, "source": SRC_ID})
|
|
assert "NOT_FOUND" in str(excinfo.value)
|
|
mock_client.sources.get_guide.assert_not_called()
|
|
|
|
|
|
async def test_source_read_invalid_detail_rejected(mcp_call, mock_client) -> None:
|
|
"""An out-of-enum ``detail`` is rejected at the Literal schema boundary, no RPC."""
|
|
mock_client.sources.get_or_none = AsyncMock()
|
|
with pytest.raises(ToolError):
|
|
await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID, "detail": "bogus"})
|
|
mock_client.sources.get_or_none.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(("arg", "value"), [("max_chars", -1), ("offset", -1)])
|
|
async def test_source_read_summary_still_validates_windowing(
|
|
mcp_call, mock_client, arg, value
|
|
) -> None:
|
|
"""Windowing args are validated UNCONDITIONALLY — a bad ``max_chars``/``offset``
|
|
errors even in summary mode (where they are ignored), never silently passes."""
|
|
mock_client.sources.get_or_none = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_read",
|
|
{"notebook": NB_ID, "source": SRC_ID, "detail": "summary", arg: value},
|
|
)
|
|
assert arg in str(excinfo.value)
|
|
mock_client.sources.get_or_none.assert_not_called()
|
|
|
|
|
|
async def test_source_rename(mcp_call, mock_client) -> None:
|
|
mock_client.sources.rename = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Renamed"))
|
|
result = await mcp_call(
|
|
"source_rename", {"notebook": NB_ID, "source": SRC_ID, "new_title": "Renamed"}
|
|
)
|
|
assert result.structured_content == {
|
|
"status": "renamed",
|
|
"source": {"id": SRC_ID, "title": "Renamed"},
|
|
"notebook_id": NB_ID,
|
|
}
|
|
mock_client.sources.rename.assert_awaited_once_with(NB_ID, SRC_ID, "Renamed")
|
|
|
|
|
|
async def test_source_delete_without_confirm_previews(mcp_call, mock_client) -> None:
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Doomed")])
|
|
mock_client.sources.delete = AsyncMock(return_value=None)
|
|
result = await mcp_call("source_delete", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content == {
|
|
"status": "needs_confirmation",
|
|
"preview": {
|
|
"action": "delete_source",
|
|
"notebook_id": NB_ID,
|
|
"source_id": SRC_ID,
|
|
"title": "Doomed",
|
|
},
|
|
}
|
|
mock_client.sources.delete.assert_not_called()
|
|
|
|
|
|
async def test_source_delete_with_confirm_deletes(mcp_call, mock_client) -> None:
|
|
mock_client.sources.delete = AsyncMock(return_value=None)
|
|
result = await mcp_call("source_delete", {"notebook": NB_ID, "source": SRC_ID, "confirm": True})
|
|
assert result.structured_content == {
|
|
"status": "deleted",
|
|
"notebook_id": NB_ID,
|
|
"source_id": SRC_ID,
|
|
}
|
|
mock_client.sources.delete.assert_awaited_once_with(NB_ID, SRC_ID)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# source_wait — both modes share ONE aggregate contract:
|
|
# {notebook_id, ok, ready, timed_out, failed, not_found,
|
|
# ready_count, timed_out_count, failed_count, not_found_count, total_count}
|
|
# ``ready`` carries _source_view rows; the three error buckets carry
|
|
# {source_id, error}. ``ok`` is True iff all three error buckets are empty. The
|
|
# ``*_count`` scalars mirror the bucket lengths and ``total_count`` folds all
|
|
# four buckets (#1822) — additive to the arrays, which stay for compatibility.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_AGGREGATE_BUCKET_KEYS = ("ready", "timed_out", "failed", "not_found")
|
|
_AGGREGATE_COUNT_KEYS = {
|
|
"ready_count",
|
|
"timed_out_count",
|
|
"failed_count",
|
|
"not_found_count",
|
|
"total_count",
|
|
}
|
|
_AGGREGATE_KEYS = {"notebook_id", "ok", *_AGGREGATE_BUCKET_KEYS} | _AGGREGATE_COUNT_KEYS
|
|
|
|
|
|
def _assert_aggregate_shape(structured: dict[str, Any]) -> None:
|
|
"""Pin the aggregate keys + count invariants so they aren't re-asserted per test."""
|
|
assert set(structured) == _AGGREGATE_KEYS
|
|
assert isinstance(structured["ok"], bool)
|
|
for key in _AGGREGATE_BUCKET_KEYS:
|
|
assert isinstance(structured[key], list)
|
|
# Each ``*_count`` scalar equals its bucket length (#1822).
|
|
assert structured[f"{key}_count"] == len(structured[key])
|
|
assert structured["total_count"] == sum(len(structured[key]) for key in _AGGREGATE_BUCKET_KEYS)
|
|
|
|
|
|
def _dispatch_wait_until_ready(by_id: dict[str, Any]) -> Any:
|
|
"""Build a ``wait_until_ready`` side_effect dispatching on the source id.
|
|
|
|
The tool calls ``wait_until_ready(notebook_id, source_id, timeout=…,
|
|
initial_interval=…)`` (per source), so ``source_id`` is the 2nd positional
|
|
arg. ``by_id`` maps a source id to either a ``FakeSource`` (returned ready) or
|
|
an ``Exception`` instance (raised) — letting one fan-out mix ready/failed/etc.
|
|
"""
|
|
|
|
def _side_effect(_notebook_id: str, source_id: str, **_kwargs: Any) -> Any:
|
|
outcome = by_id[source_id]
|
|
if isinstance(outcome, BaseException):
|
|
raise outcome
|
|
return outcome
|
|
|
|
return AsyncMock(side_effect=_side_effect)
|
|
|
|
|
|
def _dispatch_wait_all(by_id: dict[str, Any]) -> Any:
|
|
"""Build a ``wait_all_until_ready`` side_effect dispatching on the source id.
|
|
|
|
The multi-source tool calls ``wait_all_until_ready(notebook_id, source_ids,
|
|
timeout=…, initial_interval=…)`` ONCE (single-snapshot loop, #1870) and gets
|
|
back one result per id, in input order. ``by_id`` maps a source id to either
|
|
a ``FakeSource`` or a terminal ``Exception`` instance — but here the failures
|
|
are RETURNED in the list, never raised, mirroring the real neutral union.
|
|
"""
|
|
|
|
def _side_effect(_notebook_id: str, source_ids: list[str], **_kwargs: Any) -> list[Any]:
|
|
return [by_id[source_id] for source_id in source_ids]
|
|
|
|
return AsyncMock(side_effect=_side_effect)
|
|
|
|
|
|
async def test_source_wait_single_source_ready(mcp_call, mock_client) -> None:
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Ready")
|
|
)
|
|
# FakeSource is a READY web_page, so the thin-content sanity check fetches its
|
|
# body; return ample content so NO warning is added (assert below pins the exact
|
|
# ready row). Without this the fetch would await a bare MagicMock and the
|
|
# swallowed TypeError would mask the assertion — green for the wrong reason.
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
assert sc["ready"] == [
|
|
{"id": SRC_ID, "title": "Ready", "kind": "web_page", "status_label": "ready"}
|
|
]
|
|
assert sc["timed_out"] == sc["failed"] == sc["not_found"] == []
|
|
|
|
|
|
async def test_source_wait_single_source_not_found(mcp_call, mock_client) -> None:
|
|
"""A resolved full-UUID source the backend can't find → ``not_found`` bucket."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(side_effect=SourceNotFoundError(SRC_ID))
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is False
|
|
assert sc["ready"] == []
|
|
assert sc["not_found"] == [{"source_id": SRC_ID, "error": f"Source not found: {SRC_ID}"}]
|
|
|
|
|
|
async def test_source_wait_single_source_timeout(mcp_call, mock_client) -> None:
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
side_effect=SourceTimeoutError(SRC_ID, 5.0, last_status=1)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is False
|
|
assert sc["timed_out"] and sc["timed_out"][0]["source_id"] == SRC_ID
|
|
assert sc["failed"] == sc["not_found"] == []
|
|
|
|
|
|
async def test_source_wait_single_source_failed(mcp_call, mock_client) -> None:
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
side_effect=SourceProcessingError(SRC_ID, status=3)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is False
|
|
assert sc["failed"] and sc["failed"][0]["source_id"] == SRC_ID
|
|
assert sc["timed_out"] == sc["not_found"] == []
|
|
|
|
|
|
async def test_source_wait_single_source_name_miss_raises(mcp_call, mock_client) -> None:
|
|
"""An UNRESOLVABLE non-UUID ``source`` ref is an input error → ToolError NOT_FOUND,
|
|
NOT a ``not_found`` bucket entry (the resolver raises before the wait loop)."""
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Other")])
|
|
mock_client.sources.wait_until_ready = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "source": "No Such Title"})
|
|
assert "NOT_FOUND" in str(excinfo.value)
|
|
mock_client.sources.wait_until_ready.assert_not_called()
|
|
|
|
|
|
async def test_source_wait_all_sources_all_ready(mcp_call, mock_client) -> None:
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[FakeSource(id=SRC_ID), FakeSource(id=SRC2_ID)]
|
|
)
|
|
mock_client.sources.wait_for_sources = AsyncMock()
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{SRC_ID: FakeSource(id=SRC_ID, title="A"), SRC2_ID: FakeSource(id=SRC2_ID, title="B")}
|
|
)
|
|
# Both fakes are READY web_pages → the thin-content check fetches each body;
|
|
# ample content ⇒ no warning (keeps the ready-id set assertion exact).
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
assert {row["id"] for row in sc["ready"]} == {SRC_ID, SRC2_ID}
|
|
assert sc["timed_out"] == sc["failed"] == sc["not_found"] == []
|
|
# The aggregate uses the single-snapshot wait_all_until_ready, NOT the
|
|
# throw-on-first wait_for_sources helper (which would discard partial progress).
|
|
mock_client.sources.wait_for_sources.assert_not_called()
|
|
|
|
|
|
async def test_source_wait_all_sources_partial_progress(mcp_call, mock_client) -> None:
|
|
"""One call mixing ready + timeout + failed + not_found keeps the ready ones
|
|
(partial progress) and sets ok=False — the core of #1669."""
|
|
ready_id, timeout_id, failed_id, missing_id = (
|
|
"10000000-0000-0000-0000-000000000001",
|
|
"20000000-0000-0000-0000-000000000002",
|
|
"30000000-0000-0000-0000-000000000003",
|
|
"40000000-0000-0000-0000-000000000004",
|
|
)
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[FakeSource(id=i) for i in (ready_id, timeout_id, failed_id, missing_id)]
|
|
)
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{
|
|
ready_id: FakeSource(id=ready_id, title="OK"),
|
|
timeout_id: SourceTimeoutError(timeout_id, 5.0),
|
|
failed_id: SourceProcessingError(failed_id, status=3),
|
|
missing_id: SourceNotFoundError(missing_id),
|
|
}
|
|
)
|
|
# The lone ready source is a READY web_page → thin-content check fetches it;
|
|
# ample content ⇒ no warning, so the ready bucket stays exactly [ready_id].
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is False
|
|
assert [row["id"] for row in sc["ready"]] == [ready_id]
|
|
assert [e["source_id"] for e in sc["timed_out"]] == [timeout_id]
|
|
assert [e["source_id"] for e in sc["failed"]] == [failed_id]
|
|
assert [e["source_id"] for e in sc["not_found"]] == [missing_id]
|
|
# Explicit counts mirror the buckets and total across all four (#1822).
|
|
assert sc["ready_count"] == 1
|
|
assert sc["timed_out_count"] == 1
|
|
assert sc["failed_count"] == 1
|
|
assert sc["not_found_count"] == 1
|
|
assert sc["total_count"] == 4
|
|
|
|
|
|
async def test_source_wait_all_sources_empty_notebook(mcp_call, mock_client) -> None:
|
|
"""A notebook with no sources → all buckets empty, ok=True."""
|
|
mock_client.sources.list = AsyncMock(return_value=[])
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc == {
|
|
"notebook_id": NB_ID,
|
|
"ok": True,
|
|
"ready": [],
|
|
"timed_out": [],
|
|
"failed": [],
|
|
"not_found": [],
|
|
"ready_count": 0,
|
|
"timed_out_count": 0,
|
|
"failed_count": 0,
|
|
"not_found_count": 0,
|
|
"total_count": 0,
|
|
}
|
|
|
|
|
|
async def test_source_wait_all_sources_forwards_interval(mcp_call, mock_client) -> None:
|
|
"""The all-sources branch honors the advertised ``timeout``/``interval`` per source."""
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID)])
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{SRC_ID: FakeSource(id=SRC_ID, title="A")}
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "timeout": 30.0, "interval": 3.0})
|
|
mock_client.sources.wait_all_until_ready.assert_awaited_once_with(
|
|
NB_ID, [SRC_ID], timeout=30.0, initial_interval=3.0
|
|
)
|
|
|
|
|
|
async def test_source_wait_all_sources_propagates_unexpected_error(mcp_call, mock_client) -> None:
|
|
"""An UNEXPECTED escape from the snapshot poll (e.g. an RPCError — not one of the
|
|
3 handled per-source failures the loop RETURNS) propagates as ToolError. The
|
|
single-snapshot loop is one coroutine, so there are no sibling pollers to leak
|
|
(#1870)."""
|
|
slow_id, raiser_id = (
|
|
"50000000-0000-0000-0000-000000000005",
|
|
"60000000-0000-0000-0000-000000000006",
|
|
)
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[FakeSource(id=slow_id), FakeSource(id=raiser_id)]
|
|
)
|
|
mock_client.sources.wait_all_until_ready = AsyncMock(side_effect=RPCError("unexpected boom"))
|
|
mock_client.sources.wait_for_sources = AsyncMock()
|
|
|
|
with pytest.raises(ToolError):
|
|
await mcp_call("source_wait", {"notebook": NB_ID})
|
|
mock_client.sources.wait_for_sources.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# source_wait — thin-content sanity warning (#1698): a READY web_page whose
|
|
# fetched text is suspiciously thin is flagged (likely dead link / soft-404 /
|
|
# paywall ghost source). web-page only; never rejects; best-effort.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_source_wait_thin_web_page_warns(mcp_call, mock_client) -> None:
|
|
"""A READY web_page with < the thin threshold (100) chars gets a warning,
|
|
yet stays READY/ok (advisory only)."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Ghost")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="tiny", char_count=4)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
assert len(sc["ready"]) == 1
|
|
row = sc["ready"][0]
|
|
assert row["id"] == SRC_ID
|
|
assert "warning" in row
|
|
assert "4 chars" in row["warning"]
|
|
assert "source_read" in row["warning"]
|
|
mock_client.sources.get_fulltext.assert_awaited_once_with(NB_ID, SRC_ID, output_format="text")
|
|
|
|
|
|
async def test_source_wait_ample_web_page_no_warning(mcp_call, mock_client) -> None:
|
|
"""A READY web_page at/above the threshold is not flagged."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Real")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 100, char_count=100)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
assert sc["ready"][0].get("warning") is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("char_count", "expect_warning"),
|
|
[
|
|
(_THIN_SOURCE_CHAR_THRESHOLD - 1, True), # just under → char-thin warning
|
|
(_THIN_SOURCE_CHAR_THRESHOLD, False), # exactly at → not flagged (gate is ``<``)
|
|
],
|
|
)
|
|
async def test_source_wait_thin_threshold_boundary(
|
|
mcp_call, mock_client, char_count: int, expect_warning: bool
|
|
) -> None:
|
|
"""Pin the char-thin boundary to the documented threshold constant, so the number
|
|
in ``docs/mcp-guide.md`` (and the copyable trigger downstream users build against)
|
|
can't silently drift from :data:`_THIN_SOURCE_CHAR_THRESHOLD`. The gate is ``<``:
|
|
``threshold - 1`` warns, exactly ``threshold`` does not."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Boundary")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * char_count, char_count=char_count)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
warning = result.structured_content["ready"][0].get("warning")
|
|
if expect_warning:
|
|
assert warning is not None
|
|
# Pin the char-thin branch specifically (not merely "some warning"): the
|
|
# message reports the count and points at source_read — the exact shape the
|
|
# doc tells downstream integrators to assert against.
|
|
assert f"{char_count} chars" in warning
|
|
assert "little/no text extracted" in warning
|
|
assert 'source_read (detail="full")' in warning
|
|
else:
|
|
assert warning is None
|
|
|
|
|
|
async def test_source_wait_zero_char_web_page_warns(mcp_call, mock_client) -> None:
|
|
"""A READY web_page with 0 extracted chars warns too — the wording covers the
|
|
not-yet-indexed / empty case rather than asserting a false 'dead link'."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Empty")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="", char_count=0)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
row = sc["ready"][0]
|
|
assert "warning" in row
|
|
assert "0 chars" in row["warning"]
|
|
assert "not-yet-indexed" in row["warning"]
|
|
|
|
|
|
async def test_source_wait_non_web_page_not_flagged_or_fetched(mcp_call, mock_client) -> None:
|
|
"""A READY non-web-page source (short pasted text) is NEVER flagged, and its
|
|
body is NEVER fetched — the kind gate protects legitimately short content."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="Short note")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock()
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
assert sc["ready"][0].get("warning") is None
|
|
mock_client.sources.get_fulltext.assert_not_called()
|
|
|
|
|
|
async def test_source_wait_thin_check_fetch_failure_degrades(mcp_call, mock_client) -> None:
|
|
"""A body-fetch failure must NEVER break the wait — it degrades to no warning."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Flaky")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(side_effect=RuntimeError("transport boom"))
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
assert sc["ready"][0].get("warning") is None
|
|
|
|
|
|
async def test_source_wait_all_sources_thin_warning_per_item(mcp_call, mock_client) -> None:
|
|
"""Across the all-sources fan-out: only the thin web_page is flagged; the ample
|
|
web_page and the non-web-page are not. ``get_fulltext`` is called for the two
|
|
web_pages only (the pasted-text source is skipped by the kind gate)."""
|
|
thin_id, ample_id, text_id = SRC_ID, SRC2_ID, "55555555-5555-5555-5555-555555555555"
|
|
mock_client.sources.list = AsyncMock(
|
|
return_value=[
|
|
FakeSource(id=thin_id),
|
|
FakeSource(id=ample_id),
|
|
FakeReadyTextSource(id=text_id),
|
|
]
|
|
)
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{
|
|
thin_id: FakeSource(id=thin_id, title="Ghost"),
|
|
ample_id: FakeSource(id=ample_id, title="Real"),
|
|
text_id: FakeReadyTextSource(id=text_id, title="Note"),
|
|
}
|
|
)
|
|
|
|
def _fulltext(_nb: str, source_id: str, **_kwargs: Any) -> Any:
|
|
char_count = 5 if source_id == thin_id else 300
|
|
return FakeFulltext(content="y" * char_count, char_count=char_count)
|
|
|
|
mock_client.sources.get_fulltext = AsyncMock(side_effect=_fulltext)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
warned = {row["id"]: row.get("warning") for row in sc["ready"]}
|
|
assert warned[thin_id] is not None and "5 chars" in warned[thin_id]
|
|
assert warned[ample_id] is None
|
|
assert warned[text_id] is None
|
|
# Only the two web_page sources are fetched; the pasted-text source is skipped.
|
|
fetched_ids = {call.args[1] for call in mock_client.sources.get_fulltext.await_args_list}
|
|
assert fetched_ids == {thin_id, ample_id}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# source_wait — soft-404 boilerplate body scan: a READY web_page that sails past
|
|
# the char-thin rule but whose (short) body matches a dead-link / error-page
|
|
# phrase is flagged. Body-only — the title is NEVER scanned.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_source_wait_soft_404_body_phrase_warns(mcp_call, mock_client) -> None:
|
|
"""A 1,766-char READY web_page whose body is 'Whoops! broken link' boilerplate
|
|
(well above the 100-char thin threshold) is flagged via the body phrase scan —
|
|
the regression for the reported soft-404."""
|
|
# Pad to EXACTLY 1766 chars so char_count == len(content) (the impl gates on
|
|
# char_count but scans content; keep the fixture honest to the reported case).
|
|
prefix = "Whoops! The page you requested has a broken link. "
|
|
body = prefix + "x" * (1766 - len(prefix))
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Article | Example")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content=body, char_count=1766)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
sc = result.structured_content
|
|
assert sc["ok"] is True
|
|
row = sc["ready"][0]
|
|
assert "warning" in row
|
|
assert "1766 chars" in row["warning"]
|
|
assert "soft-404" in row["warning"]
|
|
assert "source_read" in row["warning"]
|
|
|
|
|
|
async def test_source_wait_long_healthy_body_with_phrase_not_flagged(mcp_call, mock_client) -> None:
|
|
"""A healthy page at/above the 2000-char body-scan limit is NOT scanned, even if
|
|
its body happens to mention 'broken link' (length-gated out)."""
|
|
body = "We explain how to find a broken link on your site. " + "content " * 400
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Fixing dead links")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content=body, char_count=2500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content["ready"][0].get("warning") is None
|
|
|
|
|
|
async def test_source_wait_body_scan_limit_boundary_not_flagged(mcp_call, mock_client) -> None:
|
|
"""Boundary: a body of EXACTLY _SOFT_404_BODY_SCAN_LIMIT (2000) chars is NOT
|
|
scanned (the gate is ``< limit``) — locks against an off-by-one if it ever
|
|
became ``<=``. The body contains a dead-link phrase, so a scan WOULD flag it."""
|
|
body = "Whoops! broken link. " + "x" * (2000 - len("Whoops! broken link. "))
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Edge")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content=body, char_count=2000)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content["ready"][0].get("warning") is None
|
|
|
|
|
|
async def test_source_wait_no_phrase_short_body_not_flagged(mcp_call, mock_client) -> None:
|
|
"""A short (sub-2000) healthy body with no dead-link phrase is not flagged."""
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Real")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="A genuine short article body.", char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content["ready"][0].get("warning") is None
|
|
|
|
|
|
async def test_source_wait_title_phrase_not_flagged(mcp_call, mock_client) -> None:
|
|
"""Locks the no-title-scan contract: a page TITLED like an error-page topic
|
|
("Broken Link Checker" / "HTTP 404 errors explained") with a clean, phrase-free
|
|
body is NOT flagged — only the body is scanned, never the title.
|
|
|
|
The body is deliberately sub-:data:`_SOFT_404_BODY_SCAN_LIMIT` (char_count=500)
|
|
so the phrase-scan path IS active — a regression that folded the title into the
|
|
scan would fire here and flip the assertion (a ``char_count >= 2000`` body would
|
|
short-circuit the scan and pass vacuously)."""
|
|
body = "A thorough guide to website maintenance tooling."
|
|
for title in ("Broken Link Checker", "HTTP 404 errors explained"):
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title=title)
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content=body, char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert result.structured_content["ready"][0].get("warning") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# source_add batch — content-sanity warning on synchronously-READY web_page items
|
|
# (Task B reuses _annotate_thin_warnings). Most adds return PROCESSING (no fetch);
|
|
# this covers the ready case + the leak guard (single mode never fetches).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_source_add_batch_ready_soft_404_carries_warning(mcp_call, mock_client) -> None:
|
|
"""A synchronously-READY web_page item whose body matches a dead-link phrase
|
|
carries the soft-404 warning in its batch result."""
|
|
body = "Whoops! that's a broken link. " + "filler " * 200
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Dead"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content=body, char_count=1500)
|
|
)
|
|
result = await mcp_call("source_add", {"notebook": NB_ID, "urls": ["https://dead.example.com"]})
|
|
item = result.structured_content["results"][0]
|
|
assert item["status"] == "added"
|
|
assert "soft-404" in item["warning"]
|
|
|
|
|
|
async def test_source_add_batch_ready_healthy_no_warning(mcp_call, mock_client) -> None:
|
|
"""A ready item with ample, healthy body carries no warning."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Real"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_add", {"notebook": NB_ID, "urls": ["https://real.example.com"]})
|
|
assert "warning" not in result.structured_content["results"][0]
|
|
|
|
|
|
async def test_source_add_batch_processing_item_not_fetched(mcp_call, mock_client) -> None:
|
|
"""A still-PROCESSING added item is not content-checked (no fetch, no warning)."""
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=FakeNotReadySource(id=SRC_ID, title="Pending")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock()
|
|
result = await mcp_call(
|
|
"source_add", {"notebook": NB_ID, "urls": ["https://pending.example.com"]}
|
|
)
|
|
item = result.structured_content["results"][0]
|
|
assert item["status"] == "added"
|
|
assert "warning" not in item
|
|
mock_client.sources.get_fulltext.assert_not_called()
|
|
|
|
|
|
async def test_source_add_batch_fetch_failure_does_not_abort(mcp_call, mock_client) -> None:
|
|
"""A content-check fetch failure degrades to no warning — the item stays added
|
|
and the batch is never aborted."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Flaky"))
|
|
mock_client.sources.get_fulltext = AsyncMock(side_effect=RuntimeError("transport boom"))
|
|
result = await mcp_call(
|
|
"source_add", {"notebook": NB_ID, "urls": ["https://flaky.example.com"]}
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["added"] == 1
|
|
item = sc["results"][0]
|
|
assert item["status"] == "added"
|
|
assert "warning" not in item
|
|
|
|
|
|
async def test_source_add_single_url_never_content_checked(mcp_call, mock_client) -> None:
|
|
"""Leak guard: single-mode source_add(source_type='url') must NEVER fetch the
|
|
body — the content-sanity helper stays confined to batch + source_wait."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="One"))
|
|
mock_client.sources.get_fulltext = AsyncMock()
|
|
await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://one.example.com"},
|
|
)
|
|
mock_client.sources.get_fulltext.assert_not_called()
|
|
|
|
|
|
async def test_source_add_text(mcp_call, mock_client) -> None:
|
|
mock_client.sources.add_text = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Notes"))
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "source_type": "text", "text": "hello world", "title": "Notes"},
|
|
)
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"status": "added",
|
|
"source": {"id": SRC_ID, "title": "Notes", "kind": "web_page", "status_label": "ready"},
|
|
}
|
|
mock_client.sources.add_text.assert_awaited_once_with(NB_ID, "Notes", "hello world")
|
|
|
|
|
|
async def test_source_add_url(mcp_call, mock_client) -> None:
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Page"))
|
|
result = await mcp_call(
|
|
"source_add", {"notebook": NB_ID, "source_type": "url", "url": "https://example.com/a"}
|
|
)
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"status": "added",
|
|
"source": {"id": SRC_ID, "title": "Page", "kind": "web_page", "status_label": "ready"},
|
|
}
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, "https://example.com/a")
|
|
|
|
|
|
async def test_source_add_surfaces_import_failure(mcp_call, mock_client) -> None:
|
|
"""When the add response already reflects ERROR, source_add flags it inline:
|
|
a top-level ``warning`` plus ``status_label='error'`` on the echoed source."""
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=FakeFailedSource(id=SRC_ID, title="Broken")
|
|
)
|
|
result = await mcp_call(
|
|
"source_add", {"notebook": NB_ID, "source_type": "url", "url": "https://example.com/bad"}
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["source"]["status_label"] == "error"
|
|
assert "warning" in sc
|
|
assert "source_delete" in sc["warning"]
|
|
|
|
|
|
async def test_source_add_drive(mcp_call, mock_client) -> None:
|
|
mock_client.sources.add_drive = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Sheet"))
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "drive",
|
|
"document_id": "drivefile123",
|
|
"title": "Sheet",
|
|
"mime_type": "google-sheets",
|
|
},
|
|
)
|
|
# SourceAddDriveResult carries the source plus the drive provenance fields.
|
|
assert result.structured_content == {
|
|
"status": "added",
|
|
"source": {"id": SRC_ID, "title": "Sheet", "kind": "web_page", "status_label": "ready"},
|
|
"notebook_id": NB_ID,
|
|
"file_id": "drivefile123",
|
|
"mime_type": "google-sheets",
|
|
}
|
|
mock_client.sources.add_drive.assert_awaited_once()
|
|
called_args = mock_client.sources.add_drive.await_args.args
|
|
assert called_args[0] == NB_ID
|
|
assert called_args[1] == "drivefile123"
|
|
|
|
|
|
async def test_source_add_missing_input_is_validation_error(mcp_call, mock_client) -> None:
|
|
"""type=url with no url projects as a VALIDATION ToolError."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_add", {"notebook": NB_ID, "source_type": "url"})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_add_drive_bad_mime_is_validation_error(mcp_call, mock_client) -> None:
|
|
"""A bogus drive mime_type projects as VALIDATION (not UNEXPECTED)."""
|
|
mock_client.sources.add_drive = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add",
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "drive",
|
|
"document_id": "drivefile123",
|
|
"mime_type": "bogus",
|
|
},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_drive.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("tool", ["source_add", "source_add_and_wait"])
|
|
async def test_source_add_drive_missing_mime_is_validation_error(
|
|
tool, mcp_call, mock_client
|
|
) -> None:
|
|
"""An omitted drive mime_type is rejected (no google-doc default) and no add
|
|
RPC runs, so no error source stub is left behind (#1827)."""
|
|
mock_client.sources.add_drive = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
tool,
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "drive",
|
|
"document_id": "drivefile123",
|
|
},
|
|
)
|
|
msg = str(excinfo.value)
|
|
assert "VALIDATION" in msg
|
|
assert "mime_type" in msg
|
|
# Rejected before resolve_notebook / the add RPC — nothing is persisted.
|
|
mock_client.sources.add_drive.assert_not_called()
|
|
mock_client.notebooks.get.assert_not_called()
|
|
|
|
|
|
async def test_source_add_mime_type_has_no_schema_enum(mcp_list_tools) -> None:
|
|
"""``mime_type`` deliberately stays a free-text ``str`` — NOT a ``Literal`` — so its
|
|
schema carries NO ``enum`` (issue #1759, decision b). It is dual-use: ``source_type=
|
|
"file"`` accepts arbitrary MIME, only ``drive`` is restricted (enforced at runtime).
|
|
A future well-meaning narrowing to ``Literal`` (which would reject valid ``file`` MIME)
|
|
fails here."""
|
|
tools = {t.name: t for t in await mcp_list_tools()}
|
|
mime_schema = tools["source_add"].inputSchema["properties"]["mime_type"]
|
|
# No flat ``enum`` and no ``enum`` inside any ``anyOf``/``oneOf`` branch (the shape a
|
|
# ``Literal | None`` would take).
|
|
assert "enum" not in mime_schema
|
|
branches = (mime_schema.get("anyOf") or []) + (mime_schema.get("oneOf") or [])
|
|
assert all("enum" not in branch for branch in branches)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source_type", "good", "foreign"),
|
|
[
|
|
# url consumes `url`; text/path/document_id are foreign.
|
|
("url", {"url": "https://example.com/a"}, {"text": "hi"}),
|
|
("url", {"url": "https://example.com/a"}, {"path": "/tmp/x"}),
|
|
("url", {"url": "https://example.com/a"}, {"document_id": "drivefile123"}),
|
|
# text consumes `text`; url/path/document_id are foreign.
|
|
("text", {"text": "hello"}, {"url": "https://example.com/a"}),
|
|
("text", {"text": "hello"}, {"path": "/tmp/x"}),
|
|
("text", {"text": "hello"}, {"document_id": "drivefile123"}),
|
|
# file consumes `path`; url/text/document_id are foreign.
|
|
("file", {"path": "/tmp/doc.pdf"}, {"url": "https://example.com/a"}),
|
|
("file", {"path": "/tmp/doc.pdf"}, {"text": "hi"}),
|
|
("file", {"path": "/tmp/doc.pdf"}, {"document_id": "drivefile123"}),
|
|
# drive consumes `document_id`; url/text/path are foreign. ``mime_type`` is
|
|
# required for drive (#1827), so it rides along in ``good`` to reach the
|
|
# foreign-scalar check (it is metadata, not a content scalar).
|
|
("drive", {"document_id": "drivefile123", "mime_type": "pdf"}, {"url": "https://e.com/a"}),
|
|
("drive", {"document_id": "drivefile123", "mime_type": "pdf"}, {"text": "hi"}),
|
|
("drive", {"document_id": "drivefile123", "mime_type": "pdf"}, {"path": "/tmp/x"}),
|
|
# youtube consumes `url`; text/path/document_id are foreign.
|
|
("youtube", {"url": "https://www.youtube.com/watch?v=abc"}, {"text": "hi"}),
|
|
("youtube", {"url": "https://www.youtube.com/watch?v=abc"}, {"path": "/tmp/x"}),
|
|
("youtube", {"url": "https://www.youtube.com/watch?v=abc"}, {"document_id": "d"}),
|
|
],
|
|
)
|
|
async def test_source_add_single_rejects_foreign_content_scalar(
|
|
mcp_call, mock_client, source_type, good, foreign
|
|
) -> None:
|
|
"""A content scalar that this source_type does not consume fails closed —
|
|
BEFORE any notebook I/O (mirrors batch mode). A notebook *title* is used so
|
|
a single ``notebooks.list`` lookup would be observable were rejection late."""
|
|
mock_client.notebooks.list = AsyncMock(return_value=[])
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add",
|
|
{"notebook": "Some Notebook", "source_type": source_type, **good, **foreign},
|
|
)
|
|
msg = str(excinfo.value)
|
|
assert "VALIDATION" in msg
|
|
# The message names the offending scalar (part of the rejection contract).
|
|
(foreign_key,) = foreign
|
|
assert foreign_key in msg
|
|
# Rejection is pre-resolve, so a name is never looked up.
|
|
mock_client.notebooks.list.assert_not_called()
|
|
|
|
|
|
async def test_source_add_single_lists_all_foreign_scalars(mcp_call, mock_client) -> None:
|
|
"""Multiple foreign content scalars are all named in one rejection."""
|
|
mock_client.notebooks.list = AsyncMock(return_value=[])
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add",
|
|
{
|
|
"notebook": "Some Notebook",
|
|
"source_type": "text",
|
|
"text": "hello",
|
|
"url": "https://example.com/a",
|
|
"path": "/tmp/x",
|
|
},
|
|
)
|
|
msg = str(excinfo.value)
|
|
assert "VALIDATION" in msg
|
|
assert "url" in msg and "path" in msg
|
|
mock_client.notebooks.list.assert_not_called()
|
|
|
|
|
|
async def test_source_add_single_metadata_not_rejected(mcp_call, mock_client) -> None:
|
|
"""``title`` / ``mime_type`` are optional metadata, NOT content scalars — a
|
|
valid single add carrying them alongside its one content scalar still works."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Page"))
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "url",
|
|
"url": "https://example.com/a",
|
|
"title": "My Page",
|
|
"mime_type": "text/html",
|
|
},
|
|
)
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"status": "added",
|
|
"source": {"id": SRC_ID, "title": "Page", "kind": "web_page", "status_label": "ready"},
|
|
}
|
|
# The add actually proceeded (not silently rejected). A url source ignores
|
|
# title/mime_type downstream — add_url takes only (notebook_id, url) — so the
|
|
# point here is that supplying them does not trip the content-scalar gate.
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, "https://example.com/a")
|
|
|
|
|
|
async def test_source_read_not_found_projects_tool_error(mcp_call, mock_client) -> None:
|
|
def _raise(*_a: Any, **_k: Any) -> Any:
|
|
raise SourceNotFoundError(SRC_ID)
|
|
|
|
mock_client.sources.get_or_none = AsyncMock(side_effect=_raise)
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert "NOT_FOUND" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_read_missing_full_uuid_projects_not_found(mcp_call, mock_client) -> None:
|
|
"""A full-UUID ref skips list resolution; a None get_or_none must NOT return
|
|
{"source": null} as success — it projects NOT_FOUND."""
|
|
mock_client.sources.get_or_none = AsyncMock(return_value=None)
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_read", {"notebook": NB_ID, "source": SRC_ID})
|
|
assert "NOT_FOUND" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_add_youtube_rejects_non_youtube_url(mcp_call, mock_client) -> None:
|
|
"""type=youtube with a non-YouTube URL projects as VALIDATION."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Page"))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "source_type": "youtube", "url": "https://example.com/not-yt"},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_youtube_accepts_youtube_url(mcp_call, mock_client) -> None:
|
|
"""type=youtube with a genuine YouTube URL is accepted."""
|
|
yt = "https://www.youtube.com/watch?v=abc123"
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Vid"))
|
|
result = await mcp_call("source_add", {"notebook": NB_ID, "source_type": "youtube", "url": yt})
|
|
assert result.structured_content == {
|
|
"notebook_id": NB_ID,
|
|
"status": "added",
|
|
"source": {"id": SRC_ID, "title": "Vid", "kind": "web_page", "status_label": "ready"},
|
|
}
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, yt)
|
|
|
|
|
|
# --- Batch mode (urls=[...]) -------------------------------------------------
|
|
|
|
|
|
async def test_source_add_batch_all_success(mcp_call, mock_client) -> None:
|
|
"""A batch of valid URLs returns a per-item ``added`` list, in input order."""
|
|
by_url = {
|
|
"https://example.com/a": FakeSource(id=SRC_ID, title="A"),
|
|
"https://example.com/b": FakeSource(id=SRC2_ID, title="B"),
|
|
}
|
|
mock_client.sources.add_url = AsyncMock(side_effect=lambda _nb, url: by_url[url])
|
|
# FakeSource is a READY web_page, so each added item is routed through the
|
|
# content-sanity helper (Task B) → mock get_fulltext with ample text so no thin
|
|
# warning is added and the strict-dict-equality below holds.
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "urls": ["https://example.com/a", "https://example.com/b"]},
|
|
)
|
|
assert result.structured_content == {
|
|
"status": "added",
|
|
"notebook_id": NB_ID,
|
|
"added": 2,
|
|
"failed": 0,
|
|
"results": [
|
|
{
|
|
"input": "https://example.com/a",
|
|
"status": "added",
|
|
"source_id": SRC_ID,
|
|
"title": "A",
|
|
"status_label": "ready",
|
|
},
|
|
{
|
|
"input": "https://example.com/b",
|
|
"status": "added",
|
|
"source_id": SRC2_ID,
|
|
"title": "B",
|
|
"status_label": "ready",
|
|
},
|
|
],
|
|
}
|
|
assert mock_client.sources.add_url.await_count == 2
|
|
# Both ready web_page items were content-checked.
|
|
assert mock_client.sources.get_fulltext.await_count == 2
|
|
|
|
|
|
async def test_source_add_batch_partial_failure(mcp_call, mock_client) -> None:
|
|
"""One bad URL does NOT abort the batch and is reported per-item, not collapsed."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Good"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "urls": ["https://good.example.com", "ftp://bad.example.com"]},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["added"] == 1
|
|
assert sc["failed"] == 1
|
|
assert sc["results"][0] == {
|
|
"input": "https://good.example.com",
|
|
"status": "added",
|
|
"source_id": SRC_ID,
|
|
"title": "Good",
|
|
"status_label": "ready",
|
|
}
|
|
bad = sc["results"][1]
|
|
assert bad["input"] == "ftp://bad.example.com"
|
|
assert bad["status"] == "error"
|
|
assert bad["error"]["code"] == "VALIDATION"
|
|
# The disallowed scheme is rejected by validate_url before reaching the client.
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, "https://good.example.com")
|
|
# The one ready item was content-checked; the rejected entry never reaches it.
|
|
mock_client.sources.get_fulltext.assert_awaited_once_with(NB_ID, SRC_ID, output_format="text")
|
|
|
|
|
|
async def test_source_add_batch_non_url_entry_errors_not_text(mcp_call, mock_client) -> None:
|
|
"""Non-URL entries error as VALIDATION — never silently added as text/file."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
mock_client.sources.add_text = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
mock_client.sources.add_file = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "urls": ["just some text", "/etc/hosts"]},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["added"] == 0
|
|
assert sc["failed"] == 2
|
|
assert [item["status"] for item in sc["results"]] == ["error", "error"]
|
|
assert all(item["error"]["code"] == "VALIDATION" for item in sc["results"])
|
|
mock_client.sources.add_url.assert_not_called()
|
|
mock_client.sources.add_text.assert_not_called()
|
|
mock_client.sources.add_file.assert_not_called()
|
|
|
|
|
|
async def test_source_add_batch_fatal_error_aborts_the_call(mcp_call, mock_client) -> None:
|
|
"""A mid-batch FATAL failure (network/auth/rate-limit/5xx) aborts the whole tool
|
|
call rather than being masked as a per-item error in a success envelope (#1871).
|
|
The batch stops at the first fatal item — the second URL is never attempted."""
|
|
mock_client.sources.add_url = AsyncMock(
|
|
side_effect=[NetworkError("boom"), FakeSource(id=SRC2_ID, title="Second")]
|
|
)
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add",
|
|
{
|
|
"notebook": NB_ID,
|
|
"urls": ["https://first.example.com", "https://second.example.com"],
|
|
},
|
|
)
|
|
assert "NETWORK" in str(excinfo.value)
|
|
# Aborted at the first fatal item — the batch did NOT continue to the second URL.
|
|
assert mock_client.sources.add_url.await_count == 1
|
|
|
|
|
|
async def test_source_add_batch_isolates_non_fatal_input_error(mcp_call, mock_client) -> None:
|
|
"""A per-URL INPUT failure (a 404 / not-found — non-fatal) is isolated as an
|
|
``error`` item while the rest of the batch proceeds (the other half of #1871)."""
|
|
mock_client.sources.add_url = AsyncMock(
|
|
side_effect=[SourceNotFoundError("missing"), FakeSource(id=SRC2_ID, title="Second")]
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "urls": ["https://first.example.com", "https://second.example.com"]},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["added"] == 1
|
|
assert sc["failed"] == 1
|
|
assert sc["results"][0]["status"] == "error"
|
|
assert sc["results"][0]["error"] == tool_error_payload(SourceNotFoundError("missing"))
|
|
assert sc["results"][1]["status"] == "added"
|
|
assert sc["results"][1]["source_id"] == SRC2_ID
|
|
# Non-fatal → the batch continued and attempted the second URL.
|
|
assert mock_client.sources.add_url.await_count == 2
|
|
|
|
|
|
async def test_source_add_batch_over_cap_rejected_before_any_add(mcp_call, mock_client) -> None:
|
|
"""More than MAX_BATCH_URLS urls is rejected as VALIDATION BEFORE any add I/O —
|
|
the cap is shared with the REST endpoint (_app.source_batch.MAX_BATCH_URLS)."""
|
|
from notebooklm._app.source_batch import MAX_BATCH_URLS
|
|
|
|
mock_client.sources.add_url = AsyncMock()
|
|
urls = [f"https://{i}.example.com" for i in range(MAX_BATCH_URLS + 1)]
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_add", {"notebook": NB_ID, "urls": urls})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_awaited()
|
|
|
|
|
|
async def test_source_wait_over_id_cap_rejected(mcp_call, mock_client) -> None:
|
|
"""More than MAX_WAIT_SOURCE_IDS explicit refs is rejected (shared cap)."""
|
|
from notebooklm._app.source_wait import MAX_WAIT_SOURCE_IDS
|
|
|
|
refs = [f"src-{i}" for i in range(MAX_WAIT_SOURCE_IDS + 1)]
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "sources": refs})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_wait_over_timeout_cap_rejected(mcp_call, mock_client) -> None:
|
|
"""A timeout above MAX_WAIT_TIMEOUT is rejected (shared cap)."""
|
|
from notebooklm._app.source_wait import MAX_WAIT_TIMEOUT
|
|
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "timeout": MAX_WAIT_TIMEOUT + 1})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_add_batch_flags_failed_import(mcp_call, mock_client) -> None:
|
|
"""An added-but-errored source is reported status='added' with status_label
|
|
'error' + an inline warning — the #1679 failure-signaling, per batch item."""
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=FakeFailedSource(id=SRC_ID, title="Broken")
|
|
)
|
|
result = await mcp_call(
|
|
"source_add", {"notebook": NB_ID, "urls": ["https://broken.example.com"]}
|
|
)
|
|
sc = result.structured_content
|
|
# The add CALL succeeded (row created) → status 'added'; the async import errored.
|
|
assert sc["added"] == 1
|
|
assert sc["failed"] == 0
|
|
item = sc["results"][0]
|
|
assert item["status"] == "added"
|
|
assert item["status_label"] == "error"
|
|
assert "Import failed" in item["warning"]
|
|
|
|
|
|
async def test_source_add_batch_propagates_cancellation(mock_client) -> None:
|
|
"""Per-item isolation must NOT swallow CancelledError (a BaseException)."""
|
|
import asyncio
|
|
|
|
from notebooklm.mcp.tools.sources import _add_url_batch
|
|
|
|
mock_client.sources.add_url = AsyncMock(side_effect=asyncio.CancelledError())
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await _add_url_batch(mock_client, NB_ID, ["https://example.com/a"], allow_internal=False)
|
|
|
|
|
|
async def test_source_add_batch_allow_internal_passthrough(mcp_call, mock_client) -> None:
|
|
"""``allow_internal`` is forwarded to every batch entry (and is not rejected)."""
|
|
internal = "http://127.0.0.1:8080/x"
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Local"))
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "urls": [internal], "allow_internal": True},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["added"] == 1
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, internal)
|
|
# The ready item is content-checked like any other added web_page.
|
|
mock_client.sources.get_fulltext.assert_awaited_once_with(NB_ID, SRC_ID, output_format="text")
|
|
|
|
|
|
async def test_source_add_batch_internal_rejected_without_allow_internal(
|
|
mcp_call, mock_client
|
|
) -> None:
|
|
"""The same internal URL errors per-item (not raised) when allow_internal is off."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
result = await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "urls": ["http://127.0.0.1:8080/x"]},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["added"] == 0
|
|
assert sc["results"][0]["status"] == "error"
|
|
assert sc["results"][0]["error"]["code"] == "VALIDATION"
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_batch_empty_array_is_validation_error(mcp_call, mock_client) -> None:
|
|
"""An empty ``urls`` list is rejected BEFORE any notebook I/O (uses a name ref)."""
|
|
mock_client.notebooks.list = AsyncMock(return_value=[])
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_add", {"notebook": "Some Notebook", "urls": []})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
# Mode validation runs before resolve_notebook, so a name is never looked up.
|
|
mock_client.notebooks.list.assert_not_called()
|
|
|
|
|
|
async def test_source_add_batch_conflicts_with_source_type(mcp_call, mock_client) -> None:
|
|
"""``urls`` together with ``source_type`` is an ambiguous-mode VALIDATION error."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "source_type": "url", "urls": ["https://example.com/a"]},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"scalar",
|
|
[
|
|
{"url": "https://example.com/x"},
|
|
{"text": "hi"},
|
|
{"title": "nope"},
|
|
{"path": "/tmp/x"},
|
|
{"document_id": "drivefile123"},
|
|
{"mime_type": "google-doc"},
|
|
],
|
|
)
|
|
async def test_source_add_batch_conflicts_with_scalar(mcp_call, mock_client, scalar) -> None:
|
|
"""ANY single-mode scalar supplied with ``urls`` is rejected (fail-closed)."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add",
|
|
{"notebook": NB_ID, "urls": ["https://example.com/a"], **scalar},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_missing_mode_is_validation_error(mcp_call, mock_client) -> None:
|
|
"""Neither ``source_type`` nor ``urls`` now fails in the body (source_type optional)."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_add", {"notebook": NB_ID})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_add_batch_youtube_accepted(mcp_call, mock_client) -> None:
|
|
"""A YouTube URL in the batch is accepted and added via add_url."""
|
|
yt = "https://www.youtube.com/watch?v=abc123"
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Vid"))
|
|
# FakeSource is a READY web_page → the Task B content-check fetches its body;
|
|
# mock ample text so no thin warning is added and the strict equality holds
|
|
# (without this the fetch hits a bare MagicMock and the swallowed error would let
|
|
# the test pass for the wrong reason).
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_add", {"notebook": NB_ID, "urls": [yt]})
|
|
sc = result.structured_content
|
|
assert sc["added"] == 1
|
|
assert sc["results"][0] == {
|
|
"input": yt,
|
|
"status": "added",
|
|
"source_id": SRC_ID,
|
|
"title": "Vid",
|
|
"status_label": "ready",
|
|
}
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, yt)
|
|
mock_client.sources.get_fulltext.assert_awaited_once_with(NB_ID, SRC_ID, output_format="text")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# source_wait subset targeting (#1745)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_source_wait_subset_ready(mcp_call, mock_client) -> None:
|
|
"""Subset mode: waits only on specified sources; returns aggregate outcomes."""
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{
|
|
SRC_ID: FakeSource(id=SRC_ID, title="A"),
|
|
SRC2_ID: FakeSource(id=SRC2_ID, title="B"),
|
|
}
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
# Subset targeting with a list of IDs.
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "sources": [SRC_ID, SRC2_ID]})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
assert {row["id"] for row in sc["ready"]} == {SRC_ID, SRC2_ID}
|
|
assert sc["timed_out"] == sc["failed"] == sc["not_found"] == []
|
|
|
|
|
|
async def test_source_wait_subset_comma_string(mcp_call, mock_client) -> None:
|
|
"""Subset mode: accepts comma-separated string, coerced to list."""
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{
|
|
SRC_ID: FakeSource(id=SRC_ID, title="A"),
|
|
SRC2_ID: FakeSource(id=SRC2_ID, title="B"),
|
|
}
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "sources": f"{SRC_ID},{SRC2_ID}"})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
assert {row["id"] for row in sc["ready"]} == {SRC_ID, SRC2_ID}
|
|
|
|
|
|
async def test_source_wait_subset_partial_failures(mcp_call, mock_client) -> None:
|
|
"""Subset mode: wait on multiple sources with some failing/timing out."""
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{
|
|
SRC_ID: FakeSource(id=SRC_ID, title="A"),
|
|
SRC2_ID: SourceTimeoutError(SRC2_ID, 5.0),
|
|
}
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "sources": [SRC_ID, SRC2_ID]})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is False
|
|
assert [row["id"] for row in sc["ready"]] == [SRC_ID]
|
|
assert [e["source_id"] for e in sc["timed_out"]] == [SRC2_ID]
|
|
|
|
|
|
async def test_source_wait_subset_unresolvable_raises(mcp_call, mock_client) -> None:
|
|
"""An unresolvable ref in subset raises NOT_FOUND before waiting."""
|
|
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="Doc")])
|
|
mock_client.sources.wait_all_until_ready = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "sources": [SRC_ID, "unresolvable_ref"]})
|
|
assert "NOT_FOUND" in str(excinfo.value)
|
|
mock_client.sources.wait_all_until_ready.assert_not_called()
|
|
|
|
|
|
async def test_source_wait_mutual_exclusion(mcp_call, mock_client) -> None:
|
|
"""Providing both source and sources raises a validation error."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "source": SRC_ID, "sources": [SRC2_ID]})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
assert "not both" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_wait_empty_sources_raises(mcp_call, mock_client) -> None:
|
|
"""Providing an empty sources list/string raises a validation error."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": NB_ID, "sources": []})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
assert "was empty" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_wait_subset_json_array_string(mcp_call, mock_client) -> None:
|
|
"""Subset mode: accepts a JSON-array string, coerced to a list (end-to-end)."""
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{
|
|
SRC_ID: FakeSource(id=SRC_ID, title="A"),
|
|
SRC2_ID: FakeSource(id=SRC2_ID, title="B"),
|
|
}
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call(
|
|
"source_wait", {"notebook": NB_ID, "sources": f'["{SRC_ID}","{SRC2_ID}"]'}
|
|
)
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert sc["ok"] is True
|
|
assert {row["id"] for row in sc["ready"]} == {SRC_ID, SRC2_ID}
|
|
|
|
|
|
async def test_source_wait_subset_dedupes(mcp_call, mock_client) -> None:
|
|
"""Subset mode: a repeated ref collapses to one ready row + one deduped poll id."""
|
|
mock_client.sources.wait_all_until_ready = _dispatch_wait_all(
|
|
{SRC_ID: FakeSource(id=SRC_ID, title="A")}
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call("source_wait", {"notebook": NB_ID, "sources": [SRC_ID, SRC_ID]})
|
|
sc = result.structured_content
|
|
_assert_aggregate_shape(sc)
|
|
assert [row["id"] for row in sc["ready"]] == [SRC_ID]
|
|
# Deduped BEFORE waiting: the single snapshot loop is handed one id, not two.
|
|
mock_client.sources.wait_all_until_ready.assert_awaited_once()
|
|
assert mock_client.sources.wait_all_until_ready.await_args.args[1] == [SRC_ID]
|
|
|
|
|
|
async def test_source_wait_empty_sources_fails_before_notebook_lookup(
|
|
mcp_call, mock_client
|
|
) -> None:
|
|
"""Empty-``sources`` is an input error that fires BEFORE any I/O.
|
|
|
|
A bad notebook ref must NOT mask the VALIDATION error: the guard runs before
|
|
``resolve_notebook``, so ``client.notebooks.list`` is never awaited.
|
|
"""
|
|
mock_client.notebooks.list = AsyncMock(side_effect=AssertionError("resolve_notebook ran"))
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_wait", {"notebook": "no-such-notebook", "sources": []})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
assert "was empty" in str(excinfo.value)
|
|
mock_client.notebooks.list.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# source_list label filter (#1745)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_source_list_label_filter(mcp_call, mock_client) -> None:
|
|
"""Filtering source list by label returns only member sources."""
|
|
|
|
mock_client.labels = MagicMock()
|
|
mock_client.labels.list = AsyncMock(
|
|
return_value=[Label(id="lbl_work", name="Work", source_ids=[SRC_ID])]
|
|
)
|
|
mock_client.labels.sources = AsyncMock(return_value=[FakeSource(id=SRC_ID, title="WorkDoc")])
|
|
|
|
result = await mcp_call("source_list", {"notebook": NB_ID, "label": "Work"})
|
|
sc = result.structured_content
|
|
assert sc["notebook_id"] == NB_ID
|
|
assert len(sc["sources"]) == 1
|
|
assert sc["sources"][0]["id"] == SRC_ID
|
|
assert sc["sources"][0]["title"] == "WorkDoc"
|
|
mock_client.labels.sources.assert_awaited_once_with(NB_ID, "lbl_work")
|
|
|
|
|
|
async def test_source_list_label_and_status_filter(mcp_call, mock_client) -> None:
|
|
"""Label and status filters compose to further narrow the result."""
|
|
|
|
mock_client.labels = MagicMock()
|
|
mock_client.labels.list = AsyncMock(
|
|
return_value=[Label(id="lbl_work", name="Work", source_ids=[SRC_ID, SRC2_ID])]
|
|
)
|
|
mock_client.labels.sources = AsyncMock(
|
|
return_value=[
|
|
FakeSource(id=SRC_ID, title="Ready Doc"),
|
|
FakeFailedSource(id=SRC2_ID, title="Broken Doc"),
|
|
]
|
|
)
|
|
|
|
result = await mcp_call("source_list", {"notebook": NB_ID, "label": "Work", "status": "error"})
|
|
sc = result.structured_content
|
|
assert len(sc["sources"]) == 1
|
|
assert sc["sources"][0]["id"] == SRC2_ID
|
|
assert sc["sources"][0]["status_label"] == "error"
|
|
|
|
|
|
async def test_source_list_unknown_label_raises(mcp_call, mock_client) -> None:
|
|
"""An unknown label raises NOT_FOUND structured error."""
|
|
mock_client.labels = MagicMock()
|
|
mock_client.labels.list = AsyncMock(return_value=[])
|
|
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_list", {"notebook": NB_ID, "label": "Unknown"})
|
|
assert "No label found matching" in str(excinfo.value)
|
|
|
|
|
|
async def test_source_list_ambiguous_label_raises(mcp_call, mock_client) -> None:
|
|
"""An ambiguous label token raises AMBIGUOUS_* structured error."""
|
|
|
|
mock_client.labels = MagicMock()
|
|
mock_client.labels.list = AsyncMock(
|
|
return_value=[
|
|
Label(id="lbl_work1", name="Work", source_ids=[]),
|
|
Label(id="lbl_work2", name="Work", source_ids=[]),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_list", {"notebook": NB_ID, "label": "Work"})
|
|
assert "matches 2 labels" in str(excinfo.value)
|
|
assert "Use a label id instead" in str(excinfo.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# source_add_and_wait — composes single-mode source_add + source_wait in one
|
|
# call. Returns the source_wait aggregate PLUS a top-level ``source_id`` (always
|
|
# present, since the source persists even when the wait fails / times out).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_source_add_and_wait_url_ready(mcp_call, mock_client) -> None:
|
|
"""URL add then wait → ready aggregate carrying the resolved source_id."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Page"))
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Page")
|
|
)
|
|
# FakeSource is a READY web_page → thin-content check fetches its body; ample
|
|
# content means NO warning is added (so the ready row asserts exactly).
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="x" * 500, char_count=500)
|
|
)
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://example.com/a"},
|
|
)
|
|
sc = result.structured_content
|
|
assert set(sc) == _AGGREGATE_KEYS | {"source_id"}
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is True
|
|
assert sc["ready"] == [
|
|
{"id": SRC_ID, "title": "Page", "kind": "web_page", "status_label": "ready"}
|
|
]
|
|
assert sc["timed_out"] == sc["failed"] == sc["not_found"] == []
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, "https://example.com/a")
|
|
# The wait polls the id returned by the add.
|
|
assert mock_client.sources.wait_until_ready.await_args.args[1] == SRC_ID
|
|
|
|
|
|
async def test_source_add_and_wait_text_ready(mcp_call, mock_client) -> None:
|
|
"""Text add then wait → ready; a pasted-text source is never thin-checked."""
|
|
mock_client.sources.add_text = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="Notes")
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="Notes")
|
|
)
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "text", "text": "hello world", "title": "Notes"},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is True
|
|
assert sc["ready"] == [
|
|
{"id": SRC_ID, "title": "Notes", "kind": "pasted_text", "status_label": "ready"}
|
|
]
|
|
mock_client.sources.add_text.assert_awaited_once_with(NB_ID, "Notes", "hello world")
|
|
mock_client.sources.get_fulltext.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_drive_ready(mcp_call, mock_client) -> None:
|
|
"""Drive add then wait → ready aggregate + source_id (no drive provenance keys)."""
|
|
mock_client.sources.add_drive = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="Sheet")
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="Sheet")
|
|
)
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "drive",
|
|
"document_id": "drivefile123",
|
|
"mime_type": "google-sheets",
|
|
},
|
|
)
|
|
sc = result.structured_content
|
|
assert set(sc) == _AGGREGATE_KEYS | {"source_id"}
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is True
|
|
mock_client.sources.add_drive.assert_awaited_once()
|
|
|
|
|
|
async def test_source_add_and_wait_drive_pdf_kind_not_spreadsheet(mcp_call, mock_client) -> None:
|
|
"""The ready aggregate labels a Drive PDF as ``pdf``, not ``google_spreadsheet``.
|
|
|
|
``wait_until_ready`` re-reads the source from GET_NOTEBOOK, where a Drive PDF
|
|
again decodes to the ambiguous type code 14 (→ GOOGLE_SPREADSHEET). The declared
|
|
``mime_type='pdf'`` must still win on the final waited/aggregated source (#1828),
|
|
just as it does for plain ``source_add``.
|
|
"""
|
|
# add_drive returns the raw ambiguous code; the core re-stamps it to PDF.
|
|
mock_client.sources.add_drive = AsyncMock(
|
|
return_value=Source(id=SRC_ID, title="Report.pdf", _type_code=14)
|
|
)
|
|
# wait_until_ready re-reads the source and again gets the ambiguous code 14.
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=Source(id=SRC_ID, title="Report.pdf", _type_code=14)
|
|
)
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "drive",
|
|
"document_id": "drivefile123",
|
|
"mime_type": "pdf",
|
|
},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["ok"] is True
|
|
(ready_row,) = sc["ready"]
|
|
assert ready_row["kind"] == "pdf"
|
|
assert ready_row["kind"] != "google_spreadsheet"
|
|
|
|
|
|
async def test_source_add_and_wait_stdio_file_ready(mcp_call, mock_client, tmp_path) -> None:
|
|
"""A stdio (local-path) file add-and-wait reaches READY — pins the file branch."""
|
|
doc = tmp_path / "doc.pdf"
|
|
doc.write_text("hello")
|
|
mock_client.sources.add_file = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="doc.pdf")
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="doc.pdf")
|
|
)
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "file", "path": str(doc)},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is True
|
|
mock_client.sources.add_file.assert_awaited_once()
|
|
|
|
|
|
async def test_source_add_and_wait_import_failure_lands_in_failed(mcp_call, mock_client) -> None:
|
|
"""An add that processes to ERROR → wait raises SourceProcessingError → ``failed``
|
|
bucket, ``ok`` False, and the source_id is still surfaced so the caller can delete it."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Bad"))
|
|
mock_client.sources.wait_until_ready = AsyncMock(side_effect=SourceProcessingError(SRC_ID))
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://example.com/bad"},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is False
|
|
assert sc["ready"] == []
|
|
assert sc["failed"] == [{"source_id": SRC_ID, "error": str(SourceProcessingError(SRC_ID))}]
|
|
|
|
|
|
async def test_source_add_and_wait_timeout_lands_in_timed_out(mcp_call, mock_client) -> None:
|
|
"""A slow import → wait raises SourceTimeoutError → ``timed_out`` bucket + source_id."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Slow"))
|
|
mock_client.sources.wait_until_ready = AsyncMock(side_effect=SourceTimeoutError(SRC_ID, 5.0))
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://example.com/slow", "timeout": 5},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is False
|
|
assert sc["timed_out"] == [{"source_id": SRC_ID, "error": str(SourceTimeoutError(SRC_ID, 5.0))}]
|
|
|
|
|
|
async def test_source_add_and_wait_thin_web_page_warns(mcp_call, mock_client) -> None:
|
|
"""A READY web page with thin text carries the same non-blocking warning source_wait adds."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Dead"))
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeSource(id=SRC_ID, title="Dead")
|
|
)
|
|
mock_client.sources.get_fulltext = AsyncMock(
|
|
return_value=FakeFulltext(content="", char_count=_THIN_SOURCE_CHAR_THRESHOLD - 1)
|
|
)
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://example.com/dead"},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["ok"] is True
|
|
assert "warning" in sc["ready"][0]
|
|
|
|
|
|
async def test_source_add_and_wait_remote_file_rejected(mcp_call, mock_client, monkeypatch) -> None:
|
|
"""A remote (http) file add-and-wait is rejected up front — the upload is a
|
|
separate step, so there is no source to wait on. No add call is made."""
|
|
import notebooklm.mcp.tools.sources as sources_mod
|
|
|
|
monkeypatch.setattr(sources_mod, "_is_http_transport", lambda: True)
|
|
mock_client.sources.add_file = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "file", "path": "/tmp/doc.pdf"},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_file.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_negative_timeout_is_validation_error(
|
|
mcp_call, mock_client
|
|
) -> None:
|
|
"""A negative timeout is rejected before any add/wait I/O."""
|
|
mock_client.sources.add_url = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://x.example", "timeout": -1},
|
|
)
|
|
assert "timeout" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_zero_interval_is_validation_error(mcp_call, mock_client) -> None:
|
|
"""A zero interval is rejected before any add/wait I/O."""
|
|
mock_client.sources.add_url = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://x.example", "interval": 0},
|
|
)
|
|
assert "interval" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_rejects_foreign_content_scalar(mcp_call, mock_client) -> None:
|
|
"""A content scalar the source_type does not consume is rejected (mirrors source_add)."""
|
|
mock_client.sources.add_url = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add_and_wait",
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "url",
|
|
"url": "https://example.com/a",
|
|
"text": "smuggled",
|
|
},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_drive_missing_document_id(mcp_call, mock_client) -> None:
|
|
"""drive add-and-wait with no document_id is a VALIDATION error, no add call."""
|
|
mock_client.sources.add_drive = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call("source_add_and_wait", {"notebook": NB_ID, "source_type": "drive"})
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_drive.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_allow_internal_passthrough(mcp_call, mock_client) -> None:
|
|
"""allow_internal lets an internal-host URL through the add SSRF guard, then waits."""
|
|
internal = "http://127.0.0.1:8080/x"
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeReadyTextSource(id=SRC_ID))
|
|
mock_client.sources.wait_until_ready = AsyncMock(return_value=FakeReadyTextSource(id=SRC_ID))
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{
|
|
"notebook": NB_ID,
|
|
"source_type": "url",
|
|
"url": internal,
|
|
"allow_internal": True,
|
|
},
|
|
)
|
|
assert result.structured_content["ok"] is True
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, internal)
|
|
|
|
|
|
async def test_source_add_and_wait_internal_rejected_without_allow_internal(
|
|
mcp_call, mock_client
|
|
) -> None:
|
|
"""Without allow_internal the SSRF guard rejects an internal-host URL before any add."""
|
|
mock_client.sources.add_url = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "http://127.0.0.1:8080/x"},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_youtube_ready(mcp_call, mock_client) -> None:
|
|
"""A youtube add-and-wait dispatches through the YouTube-host guard, then waits."""
|
|
yt = "https://www.youtube.com/watch?v=abc"
|
|
mock_client.sources.add_url = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="Vid")
|
|
)
|
|
mock_client.sources.wait_until_ready = AsyncMock(
|
|
return_value=FakeReadyTextSource(id=SRC_ID, title="Vid")
|
|
)
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "youtube", "url": yt},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is True
|
|
mock_client.sources.add_url.assert_awaited_once_with(NB_ID, yt)
|
|
|
|
|
|
async def test_source_add_and_wait_youtube_rejects_non_youtube_url(mcp_call, mock_client) -> None:
|
|
"""source_type='youtube' with a non-YouTube URL is a VALIDATION error, no add."""
|
|
mock_client.sources.add_url = AsyncMock()
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "youtube", "url": "https://example.com/x"},
|
|
)
|
|
assert "VALIDATION" in str(excinfo.value)
|
|
mock_client.sources.add_url.assert_not_called()
|
|
|
|
|
|
async def test_source_add_and_wait_not_found_bucket(mcp_call, mock_client) -> None:
|
|
"""A wait that raises SourceNotFoundError → ``not_found`` bucket + top-level source_id."""
|
|
mock_client.sources.add_url = AsyncMock(return_value=FakeSource(id=SRC_ID, title="Ghost"))
|
|
mock_client.sources.wait_until_ready = AsyncMock(side_effect=SourceNotFoundError(SRC_ID))
|
|
result = await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://example.com/ghost"},
|
|
)
|
|
sc = result.structured_content
|
|
assert sc["source_id"] == SRC_ID
|
|
assert sc["ok"] is False
|
|
assert sc["not_found"] == [{"source_id": SRC_ID, "error": f"Source not found: {SRC_ID}"}]
|
|
|
|
|
|
async def test_source_add_and_wait_add_failure_raises_no_bucket(mcp_call, mock_client) -> None:
|
|
"""When the ADD itself fails (e.g. an RPCError), no source exists to wait on: the
|
|
error flows through mcp_errors() as a tool error — it does NOT land in a wait
|
|
bucket, and no source_id is returned. The wait is never reached."""
|
|
mock_client.sources.add_url = AsyncMock(side_effect=RPCError("boom"))
|
|
mock_client.sources.wait_until_ready = AsyncMock()
|
|
with pytest.raises(ToolError):
|
|
await mcp_call(
|
|
"source_add_and_wait",
|
|
{"notebook": NB_ID, "source_type": "url", "url": "https://example.com/a"},
|
|
)
|
|
mock_client.sources.wait_until_ready.assert_not_called()
|