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
125 lines
4.4 KiB
Python
125 lines
4.4 KiB
Python
"""Fixtures for MCP tool/server unit tests.
|
|
|
|
The MCP server is exercised through FastMCP's in-memory :class:`fastmcp.Client`
|
|
against a server whose lifespan yields a mocked ``NotebookLMClient`` (injected
|
|
via the ``client_factory`` seam). Tests configure the mock's namespace methods
|
|
(``mock_client.notebooks.list = AsyncMock(return_value=...)``) and assert on the
|
|
serialized ``structured_content`` plus that the right API method was called.
|
|
|
|
Return values can be plain local ``@dataclass`` fakes — ``to_jsonable`` converts
|
|
any dataclass — so tests need not construct real core types.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import importlib.util
|
|
from collections.abc import AsyncIterator, Callable
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
# The canonical contributor install (`uv sync --frozen --extra browser --extra dev
|
|
# --extra markdown`) omits the `mcp` extra, so `fastmcp` may be absent. A bare
|
|
# top-level ``pytest.importorskip`` in a *conftest* raises during conftest import,
|
|
# which pytest reports as a collection ERROR (not a clean skip) — so instead we
|
|
# tell pytest to ignore this whole directory when fastmcp is missing. The default
|
|
# `uv run pytest` then stays green without the extra; with the extra these tests
|
|
# RUN. Each test/guardrail module that imports fastmcp also carries its own
|
|
# ``pytest.importorskip("fastmcp")`` as a belt-and-suspenders guard.
|
|
def _fastmcp_available() -> bool:
|
|
"""Whether the optional ``mcp`` extra (fastmcp) is importable.
|
|
|
|
``find_spec`` returns ``None`` for an absent leaf but can *raise*
|
|
``ModuleNotFoundError`` if an ancestor is missing — treat either as absent.
|
|
"""
|
|
try:
|
|
return importlib.util.find_spec("fastmcp") is not None
|
|
except ModuleNotFoundError:
|
|
return False
|
|
|
|
|
|
collect_ignore_glob: list[str] = []
|
|
if not _fastmcp_available():
|
|
collect_ignore_glob = ["*"]
|
|
else:
|
|
from fastmcp import Client, FastMCP
|
|
|
|
from notebooklm.mcp.server import create_server
|
|
|
|
# Public client namespaces the tools reach through. Each is a MagicMock whose
|
|
# async methods tests override with AsyncMock.
|
|
_NAMESPACES = (
|
|
"notebooks",
|
|
"sources",
|
|
"chat",
|
|
"artifacts",
|
|
"research",
|
|
"notes",
|
|
"sharing",
|
|
"labels",
|
|
"settings",
|
|
"mind_maps",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_client() -> MagicMock:
|
|
"""A ``MagicMock`` standing in for ``NotebookLMClient`` with namespace attrs."""
|
|
client = MagicMock()
|
|
for namespace in _NAMESPACES:
|
|
setattr(client, namespace, MagicMock())
|
|
# `_app.download.execute_download` probes `client.artifacts._list_for_download`
|
|
# (the #1488 raw-rows fast path). A bare MagicMock auto-vivifies it as a
|
|
# truthy, non-awaitable attr; pin it to None so download tests exercise the
|
|
# public `.list` fallback they mock (the fast path is covered by the _app
|
|
# download tests, which use the real client).
|
|
client.artifacts._list_for_download = None
|
|
# Identity accessors used by ``server_info(include_account=True)`` — both are
|
|
# top-level client methods (not namespace attrs), so pin them explicitly:
|
|
# ``get_account_email`` is awaited, ``get_account_authuser`` is sync.
|
|
client.get_account_email = AsyncMock(return_value=None)
|
|
client.get_account_authuser = MagicMock(return_value=0)
|
|
return client
|
|
|
|
|
|
def _server_for(mock_client: MagicMock) -> FastMCP:
|
|
@contextlib.asynccontextmanager
|
|
async def factory() -> AsyncIterator[MagicMock]:
|
|
yield mock_client
|
|
|
|
return create_server(client_factory=factory)
|
|
|
|
|
|
@pytest.fixture
|
|
def server_factory(mock_client: MagicMock) -> Callable[[], FastMCP]:
|
|
"""Return a zero-arg builder for a server bound to ``mock_client``."""
|
|
return lambda: _server_for(mock_client)
|
|
|
|
|
|
@pytest.fixture
|
|
def mcp_call(mock_client: MagicMock) -> Callable[..., Any]:
|
|
"""Return ``async (tool_name, args=None) -> ToolResult`` against the mock."""
|
|
|
|
async def _call(tool_name: str, args: dict[str, Any] | None = None) -> Any:
|
|
async with Client(_server_for(mock_client)) as client:
|
|
return await client.call_tool(tool_name, args or {})
|
|
|
|
return _call
|
|
|
|
|
|
@pytest.fixture
|
|
def mcp_list_tools(mock_client: MagicMock) -> Callable[[], Any]:
|
|
"""Return ``async () -> list[Tool]`` (the registered tool manifest)."""
|
|
|
|
async def _list() -> Any:
|
|
async with Client(_server_for(mock_client)) as client:
|
|
return await client.list_tools()
|
|
|
|
return _list
|
|
|
|
|
|
__all__ = ["AsyncMock"] # re-exported for convenience in tool tests
|