Files
teng-lin--notebooklm-py/tests/unit/mcp/test_sources.py
T
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

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()