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

392 lines
14 KiB
Python

"""Integration tests for NotesAPI.
Moved from ``tests/unit/`` to ``tests/integration/``.
Mock-backed (``pytest_httpx``); ``allow_no_vcr`` opts out of the
integration-tree VCR enforcement hook in ``tests/integration/conftest.py``.
Cassette-backed coverage lives in ``tests/integration/test_vcr_comprehensive.py``.
"""
import pytest
from pytest_httpx import HTTPXMock
from notebooklm import NotebookLMClient
from notebooklm.exceptions import NoteNotFoundError, RPCError
from notebooklm.rpc import RPCMethod
pytestmark = pytest.mark.allow_no_vcr
class TestNotesAPI:
"""Integration tests for the NotesAPI."""
@pytest.mark.asyncio
async def test_list_notes(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test listing notes in a notebook."""
response = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[
[
["note_001", ["note_001", "Note content 1", None, None, "My First Note"]],
["note_002", ["note_002", "Note content 2", None, None, "My Second Note"]],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notes = await client.notes.list("nb_123")
assert len(notes) == 2
assert notes[0].id == "note_001"
assert notes[0].title == "My First Note"
assert notes[0].content == "Note content 1"
assert notes[1].id == "note_002"
assert notes[1].title == "My Second Note"
@pytest.mark.asyncio
async def test_list_notes_empty(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test listing notes when notebook is empty."""
response = build_rpc_response(RPCMethod.GET_NOTES_AND_MIND_MAPS, [[]])
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notes = await client.notes.list("nb_123")
assert notes == []
@pytest.mark.asyncio
async def test_list_notes_excludes_mind_maps(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test that list() filters out mind maps."""
response = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[
[
["note_001", ["note_001", "Regular note content", None, None, "Regular Note"]],
[
"mm_001",
["mm_001", '{"title":"Mind Map","children":[]}', None, None, "Mind Map"],
],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notes = await client.notes.list("nb_123")
assert len(notes) == 1
assert notes[0].id == "note_001"
@pytest.mark.asyncio
async def test_get_note(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test getting a specific note by ID."""
response = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[
[
["note_001", ["note_001", "Content 1", None, None, "Note 1"]],
["note_002", ["note_002", "Content 2", None, None, "Note 2"]],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
note = await client.notes.get("nb_123", "note_002")
assert note is not None
assert note.id == "note_002"
assert note.title == "Note 2"
assert note.content == "Content 2"
@pytest.mark.asyncio
async def test_get_note_not_found(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test getting a note that doesn't exist raises NoteNotFoundError."""
response = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[
[
["note_001", ["note_001", "Content", None, None, "Title"]],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
# v0.8.0: a miss now raises NoteNotFoundError (issue #1247).
with pytest.raises(NoteNotFoundError):
await client.notes.get("nb_123", "nonexistent")
@pytest.mark.asyncio
async def test_list_notes_populates_created_at(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""``notes.list`` decodes the per-row creation timestamp (issue #1529).
The timestamp lives in the note metadata envelope at
``row[1][2][2][0]``. Pin the EPOCH INT (TZ-invariant) rather than a
wall-time string so the assertion is host-timezone independent.
"""
metadata = [1, "400237754469", [1768312234, 146794000]]
response = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[[["note_001", ["note_001", "Body", metadata, None, "Timestamped Note"]]]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notes = await client.notes.list("nb_123")
assert len(notes) == 1
assert notes[0].created_at is not None
assert int(notes[0].created_at.timestamp()) == 1768312234
@pytest.mark.asyncio
async def test_get_note_populates_created_at(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""``notes.get`` decodes the creation timestamp (issue #1529)."""
metadata = [1, "400237754469", [1768311078, 286553000]]
response = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[[["note_001", ["note_001", "Body", metadata, None, "Title"]]]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
note = await client.notes.get("nb_123", "note_001")
assert note.created_at is not None
assert int(note.created_at.timestamp()) == 1768311078
@pytest.mark.asyncio
async def test_create_note(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test creating a new note."""
create_response = build_rpc_response(RPCMethod.CREATE_NOTE, [["new_note_id"]])
httpx_mock.add_response(content=create_response.encode())
update_response = build_rpc_response(RPCMethod.UPDATE_NOTE, None)
httpx_mock.add_response(content=update_response.encode())
async with NotebookLMClient(auth_tokens) as client:
note = await client.notes.create("nb_123", "My Title", "My Content")
assert note.id == "new_note_id"
assert note.title == "My Title"
assert note.content == "My Content"
# A CREATE_NOTE response with no metadata envelope leaves created_at
# genuinely absent (soft None), not a fabricated value.
assert note.created_at is None
requests = httpx_mock.get_requests()
assert RPCMethod.CREATE_NOTE in str(requests[0].url)
assert RPCMethod.UPDATE_NOTE in str(requests[1].url)
@pytest.mark.asyncio
async def test_create_note_populates_created_at(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""``notes.create`` decodes the create-time timestamp when the
CREATE_NOTE response carries the metadata envelope (issue #1529).
The wire row is the bare inner envelope
``[id, content, metadata, None, title]``; ``create_note`` wraps it as
``[id, inner]`` so ``NoteRow``'s centralised ``row[1][2][2][0]`` descent
reads the epoch. Pin the EPOCH INT (TZ-invariant).
"""
inner = ["new_note_id", "", [1, "400237754469", [1768312234, 146794000]], None, "My Title"]
create_response = build_rpc_response(RPCMethod.CREATE_NOTE, [inner])
httpx_mock.add_response(content=create_response.encode())
update_response = build_rpc_response(RPCMethod.UPDATE_NOTE, None)
httpx_mock.add_response(content=update_response.encode())
async with NotebookLMClient(auth_tokens) as client:
note = await client.notes.create("nb_123", "My Title", "My Content")
assert note.id == "new_note_id"
assert note.created_at is not None
assert int(note.created_at.timestamp()) == 1768312234
@pytest.mark.asyncio
async def test_create_note_populates_created_at_flat_shape(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""``notes.create`` decodes the timestamp from the FLAT CREATE_NOTE
shape too (issue #1529).
Here ``result`` IS the inner envelope (``result[0]`` is the string id),
so the timestamp lives at ``result[2][2][0]``. The flat path must yield
the SAME decoded epoch as the wrapped path — not ``None``. Pin the EPOCH
INT (TZ-invariant).
"""
inner = ["new_note_id", "", [1, "400237754469", [1768312234, 146794000]], None, "My Title"]
# Flat shape: the inner envelope is the RPC result directly (no outer
# wrapping list around the row).
create_response = build_rpc_response(RPCMethod.CREATE_NOTE, inner)
httpx_mock.add_response(content=create_response.encode())
update_response = build_rpc_response(RPCMethod.UPDATE_NOTE, None)
httpx_mock.add_response(content=update_response.encode())
async with NotebookLMClient(auth_tokens) as client:
note = await client.notes.create("nb_123", "My Title", "My Content")
assert note.id == "new_note_id"
assert note.created_at is not None
assert int(note.created_at.timestamp()) == 1768312234
@pytest.mark.asyncio
async def test_create_note_raises_when_id_unparseable(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""A CREATE_NOTE payload with no extractable id must raise, not
return a success-shaped ``Note(id="")`` (issue #1162)."""
# ``[[]]`` parses but yields no note id (empty inner row).
create_response = build_rpc_response(RPCMethod.CREATE_NOTE, [[]])
httpx_mock.add_response(content=create_response.encode())
async with NotebookLMClient(auth_tokens) as client:
with pytest.raises(RPCError, match="no usable note id"):
await client.notes.create("nb_123", "My Title", "My Content")
# The finalize UPDATE_NOTE must never have been attempted: bailing
# before UPDATE_NOTE is what prevents the degenerate empty-id note.
requests = httpx_mock.get_requests()
assert len(requests) == 1
assert RPCMethod.CREATE_NOTE in str(requests[0].url)
@pytest.mark.asyncio
async def test_update_note(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test updating an existing note."""
# v0.8.0 (#1362): update() runs a GET_NOTES_AND_MIND_MAPS existence
# preflight first; the note must be present so the UPDATE_NOTE RPC fires.
preflight = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[[["note_001", ["note_001", "Existing", None, None, "Note"]]]],
)
httpx_mock.add_response(content=preflight.encode())
response = build_rpc_response(RPCMethod.UPDATE_NOTE, None)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notes.update("nb_123", "note_001", "Updated content", "Updated title")
update_request = httpx_mock.get_requests()[-1]
assert RPCMethod.UPDATE_NOTE in str(update_request.url)
assert "source-path=%2Fnotebook%2Fnb_123" in str(update_request.url)
@pytest.mark.asyncio
async def test_delete_note(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test deleting a note."""
response = build_rpc_response(RPCMethod.DELETE_NOTE, None)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
result = await client.notes.delete("nb_123", "note_001")
assert result is None
request = httpx_mock.get_request()
assert RPCMethod.DELETE_NOTE in str(request.url)
@pytest.mark.asyncio
async def test_list_mind_maps(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test listing mind maps in a notebook."""
response = build_rpc_response(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
[
[
["note_001", ["note_001", "Regular note", None, None, "Note"]],
[
"mm_001",
["mm_001", '{"title":"Mind Map 1","children":[]}', None, None, "MM1"],
],
["mm_002", ["mm_002", '{"nodes":[{"id":"1"}]}', None, None, "MM2"]],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
mind_maps = await client.notes.list_mind_maps("nb_123")
assert len(mind_maps) == 2
@pytest.mark.asyncio
async def test_delete_mind_map(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test deleting a mind map."""
response = build_rpc_response(RPCMethod.DELETE_NOTE, None)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
result = await client.notes.delete_mind_map("nb_123", "mm_001")
assert result is None
request = httpx_mock.get_request()
assert RPCMethod.DELETE_NOTE in str(request.url)