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

128 lines
4.9 KiB
Python

"""Unit tests for the note MCP tools.
Drives each tool through the in-memory FastMCP ``Client`` against the mocked
``NotebookLMClient``, asserting the serialized ``structured_content``. Covers the
happy path, name-vs-id resolution (notebook + note) reaching the tool, the
confirm preview-then-delete flow, and error projection.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
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.exceptions import NoteNotFoundError # noqa: E402 - after importorskip guard
from .conftest import AsyncMock # noqa: E402 - after importorskip guard
@dataclass
class FakeNote:
id: str
title: str
content: str = ""
NB_ID = "11111111-1111-1111-1111-111111111111"
NOTE_ID = "55555555-5555-5555-5555-555555555555"
async def test_note_save_create(mcp_call, mock_client) -> None:
# notes.create returns a typed Note (the facade trusts the contract and
# reads note.id — no raw RPC-shape extraction above the facade).
mock_client.notes.create = AsyncMock(
return_value=FakeNote(id=NOTE_ID, title="Idea", content="body")
)
result = await mcp_call("note_save", {"notebook": NB_ID, "title": "Idea", "content": "body"})
assert result.structured_content == {
"status": "created",
"notebook_id": NB_ID,
"title": "Idea",
"note_id": NOTE_ID,
"created": True,
}
mock_client.notes.create.assert_awaited_once_with(NB_ID, "Idea", "body")
@pytest.mark.parametrize("payload", [{"title": "T"}, {"content": "C"}, {}])
async def test_note_save_create_requires_title_and_content(mcp_call, mock_client, payload) -> None:
"""Create mode (no ``note``) requires BOTH title and content; else a clean error."""
mock_client.notes.create = AsyncMock()
with pytest.raises(ToolError) as exc:
await mcp_call("note_save", {"notebook": NB_ID, **payload})
assert "required to create" in str(exc.value)
mock_client.notes.create.assert_not_called()
async def test_note_save_update(mcp_call, mock_client) -> None:
mock_client.notes.update = AsyncMock(return_value=None)
result = await mcp_call(
"note_save", {"notebook": NB_ID, "note": NOTE_ID, "content": "new body"}
)
assert result.structured_content == {
"status": "updated",
"notebook_id": NB_ID,
"note_id": NOTE_ID,
}
mock_client.notes.update.assert_awaited_once_with(
NB_ID, NOTE_ID, content="new body", title=None
)
async def test_note_save_update_resolves_note_by_name(mcp_call, mock_client) -> None:
"""A non-id ``note`` ref resolves by exact title within the notebook."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=NOTE_ID, title="My Note", content="x")]
)
mock_client.notes.update = AsyncMock(return_value=None)
result = await mcp_call("note_save", {"notebook": NB_ID, "note": "My Note", "content": "y"})
assert result.structured_content["note_id"] == NOTE_ID
mock_client.notes.update.assert_awaited_once_with(NB_ID, NOTE_ID, content="y", title=None)
async def test_note_save_update_title_only_renames(mcp_call, mock_client) -> None:
"""Title-only update passes the title through (content stays None = unchanged)."""
mock_client.notes.update = AsyncMock(return_value=None)
result = await mcp_call("note_save", {"notebook": NB_ID, "note": NOTE_ID, "title": "Renamed"})
assert result.structured_content == {
"status": "updated",
"notebook_id": NB_ID,
"note_id": NOTE_ID,
}
mock_client.notes.update.assert_awaited_once_with(NB_ID, NOTE_ID, content=None, title="Renamed")
async def test_note_save_update_title_and_content(mcp_call, mock_client) -> None:
mock_client.notes.update = AsyncMock(return_value=None)
await mcp_call(
"note_save",
{"notebook": NB_ID, "note": NOTE_ID, "content": "body", "title": "T"},
)
mock_client.notes.update.assert_awaited_once_with(NB_ID, NOTE_ID, content="body", title="T")
async def test_note_save_update_no_fields_errors(mcp_call, mock_client) -> None:
"""Neither content nor title supplied → validation error, no RPC."""
mock_client.notes.update = AsyncMock(return_value=None)
with pytest.raises(ToolError) as excinfo:
await mcp_call("note_save", {"notebook": NB_ID, "note": NOTE_ID})
assert "VALIDATION" in str(excinfo.value)
mock_client.notes.update.assert_not_called()
async def test_note_save_update_not_found_projects_tool_error(mcp_call, mock_client) -> None:
def _raise(*_a: Any, **_k: Any) -> Any:
raise NoteNotFoundError(NOTE_ID)
mock_client.notes.update = AsyncMock(side_effect=_raise)
with pytest.raises(ToolError) as excinfo:
await mcp_call("note_save", {"notebook": NB_ID, "note": NOTE_ID, "content": "z"})
assert "NOT_FOUND" in str(excinfo.value)