Files
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

682 lines
25 KiB
Python

"""Unit tests for notebook operations."""
import asyncio
import logging
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._notebooks import (
NotebooksAPI,
build_create_notebook_params,
build_get_notebook_params,
)
from notebooklm._source.listing import SourceLister
from notebooklm.auth import AuthTokens
from notebooklm.client import NotebookLMClient
from notebooklm.exceptions import (
NetworkError,
NotebookLimitError,
NotebookNotFoundError,
RPCError,
)
from notebooklm.rpc import RPCMethod
from notebooklm.types import AccountLimits, Notebook, NotebookMetadata, Source, SourceType
def _make_core(rpc_call: AsyncMock | None = None):
"""Return a fake collaborator core with a pre-wired ``rpc_call``.
ADR-0007 substrate: built via :func:`make_fake_core` so the resulting
bag-of-attributes satisfies the capability Protocols without re-
introducing the forbidden post-construction AsyncMock attribute-
assignment pattern. Callers that need to control the dispatch
behaviour pass a pre-built ``rpc_call`` here.
"""
from tests._fixtures.fake_core import make_fake_core
return make_fake_core(rpc_call=rpc_call if rpc_call is not None else AsyncMock())
def _make_api(rpc_call: AsyncMock | None = None) -> NotebooksAPI:
core = _make_core(rpc_call)
return NotebooksAPI(core.rpc_executor, sources_api=MagicMock())
def _source_entry(
source_id: str,
*,
title: str = "Source",
metadata: list[Any] | None = None,
) -> list[Any]:
return [
[source_id],
title,
metadata or [None, 11, [1704067200, 0], None, 5],
[None, 2],
]
def _owned_notebooks(count: int) -> list[Notebook]:
return [Notebook(id=f"owned_{i}", title=f"Owned {i}", is_owner=True) for i in range(count)]
def _shared_notebooks(count: int) -> list[Notebook]:
return [Notebook(id=f"shared_{i}", title=f"Shared {i}", is_owner=False) for i in range(count)]
def _create_invalid_argument_error(
*, method_id: str = RPCMethod.CREATE_NOTEBOOK.value, rpc_code: int = 3
) -> RPCError:
return RPCError(
"RPC CCqFvf returned null result with status code 3 (Invalid argument).",
method_id=method_id,
rpc_code=rpc_code,
)
def test_build_create_notebook_params_matches_live_payload() -> None:
# Nested trailing block per the Gemini-3.5 wire-format migration (#1546).
assert build_create_notebook_params("Daily News") == [
"Daily News",
None,
None,
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
]
def test_build_get_notebook_params_matches_live_payload() -> None:
# #1549: the read-path tail also migrated to the nested template block.
# Live-verified forward-compatible (decoded notebook + sources byte-identical
# to the old flat ``[2]`` on an un-migrated account). The trailing ``None, 0``
# is unchanged — only position 2 migrates.
assert build_get_notebook_params("nb_abc") == [
"nb_abc",
None,
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
None,
0,
]
def test_direct_notebooks_api_construction_remains_supported() -> None:
core = _make_core()
api = NotebooksAPI(core.rpc_executor)
assert hasattr(api, "_sources")
assert isinstance(api._sources, SourceLister)
@pytest.mark.asyncio
async def test_direct_notebooks_api_get_metadata_uses_phase8_source_lister() -> None:
core = _make_core()
core.rpc_executor.rpc_call.return_value = [
[
"Architecture",
[_source_entry("src_1", title="Design Paper", metadata=[None, 11, None, None, 3])],
"nb_123",
]
]
api = NotebooksAPI(core.rpc_executor)
metadata = await api.get_metadata("nb_123")
assert metadata.notebook == Notebook(id="nb_123", title="Architecture", sources_count=1)
assert len(metadata.sources) == 1
assert metadata.sources[0].kind == SourceType.PDF
assert metadata.sources[0].title == "Design Paper"
assert core.rpc_executor.rpc_call.await_count == 2
@pytest.mark.asyncio
async def test_direct_notebooks_api_metadata_lister_uses_late_bound_rpc_executor_call() -> None:
core = _make_core()
api = NotebooksAPI(core.rpc_executor)
replacement_rpc = AsyncMock(
return_value=[
[
"Late Bound",
[_source_entry("src_1", title="Design Paper", metadata=[None, 11, None, None, 3])],
"nb_123",
]
]
)
core.rpc_executor.rpc_call = replacement_rpc
metadata = await api.get_metadata("nb_123")
assert metadata.title == "Late Bound"
assert metadata.sources[0].kind == SourceType.PDF
assert replacement_rpc.await_count == 2
@pytest.mark.asyncio
async def test_client_wires_sources_api_into_notebooks_as_structural_lister() -> None:
auth = AuthTokens(
cookies={"SID": "test_sid", "__Secure-1PSIDTS": "test_1psidts", "HSID": "test_hsid"},
csrf_token="test_csrf",
session_id="test_session",
)
client = NotebookLMClient(auth)
client.notebooks.get = AsyncMock(
return_value=Notebook(id="nb_123", title="Client", sources_count=1)
)
client.sources.list = AsyncMock(return_value=[Source(id="src_1", title="Paper", _type_code=3)])
metadata = await client.notebooks.get_metadata("nb_123")
assert metadata.notebook.title == "Client"
assert metadata.sources[0].kind == SourceType.PDF
client.sources.list.assert_awaited_once_with("nb_123")
@pytest.mark.asyncio
async def test_get_metadata_uses_injected_source_lister_and_builds_summaries() -> None:
core = _make_core()
source_lister = MagicMock()
source_lister.list = AsyncMock(
return_value=[
Source(
id="src_1",
title="Architecture Notes",
url="https://example.com/notes",
_type_code=5, # SourceType.WEB_PAGE
)
]
)
api = NotebooksAPI(core.rpc_executor, sources_api=source_lister)
api.get = AsyncMock(return_value=Notebook(id="nb_123", title="Architecture", sources_count=1))
metadata = await api.get_metadata("nb_123")
assert isinstance(metadata, NotebookMetadata)
assert metadata.notebook == Notebook(id="nb_123", title="Architecture", sources_count=1)
assert len(metadata.sources) == 1
assert metadata.sources[0].kind == SourceType.WEB_PAGE
assert metadata.sources[0].title == "Architecture Notes"
assert metadata.sources[0].url == "https://example.com/notes"
api.get.assert_awaited_once_with("nb_123")
source_lister.list.assert_awaited_once_with("nb_123")
@pytest.mark.asyncio
async def test_get_metadata_fetches_notebook_and_sources_concurrently() -> None:
core = _make_core()
source_lister = MagicMock()
get_started = asyncio.Event()
list_started = asyncio.Event()
release = asyncio.Event()
async def get_notebook(notebook_id: str) -> Notebook:
assert notebook_id == "nb_123"
get_started.set()
await list_started.wait()
await release.wait()
return Notebook(id="nb_123", title="Concurrent", sources_count=1)
async def list_sources(notebook_id: str) -> list[Source]:
assert notebook_id == "nb_123"
list_started.set()
await get_started.wait()
await release.wait()
return [Source(id="src_1", title="Paper", _type_code=3)] # SourceType.PDF
source_lister.list = AsyncMock(side_effect=list_sources)
api = NotebooksAPI(core.rpc_executor, sources_api=source_lister)
api.get = AsyncMock(side_effect=get_notebook)
metadata_task = asyncio.create_task(api.get_metadata("nb_123"))
await asyncio.wait_for(get_started.wait(), timeout=1)
await asyncio.wait_for(list_started.wait(), timeout=1)
assert not metadata_task.done()
release.set()
metadata = await metadata_task
assert metadata.notebook.title == "Concurrent"
assert metadata.sources[0].kind == SourceType.PDF
@pytest.mark.asyncio
async def test_get_metadata_warns_when_notebook_reports_sources_but_listing_is_empty(
caplog: pytest.LogCaptureFixture,
) -> None:
core = _make_core()
source_lister = MagicMock()
source_lister.list = AsyncMock(return_value=[])
api = NotebooksAPI(core.rpc_executor, sources_api=source_lister)
api.get = AsyncMock(return_value=Notebook(id="nb_123", title="Sparse", sources_count=2))
with caplog.at_level(logging.WARNING, logger="notebooklm._notebooks"):
metadata = await api.get_metadata("nb_123")
assert metadata.sources == []
assert "Notebook nb_123 reports 2 sources but listing returned empty" in caplog.text
@pytest.mark.asyncio
async def test_get_metadata_does_not_warn_when_empty_notebook_listing_is_empty(
caplog: pytest.LogCaptureFixture,
) -> None:
core = _make_core()
source_lister = MagicMock()
source_lister.list = AsyncMock(return_value=[])
api = NotebooksAPI(core.rpc_executor, sources_api=source_lister)
api.get = AsyncMock(return_value=Notebook(id="nb_123", title="Empty", sources_count=0))
with caplog.at_level(logging.WARNING, logger="notebooklm._notebooks"):
metadata = await api.get_metadata("nb_123")
assert metadata.sources == []
assert caplog.records == []
def test_share_method_removed_in_v080() -> None:
"""NotebooksAPI.share() was removed in v0.8.0 (#1363).
The deprecated no-behavior-change wrapper over ``client.sharing.set_public``
is gone; the SHARE_ARTIFACT payload wiring it forwarded to lives in
``ShareManager.share`` (independently tested). Callers use
``client.sharing.set_public`` for the toggle and ``get_share_url`` for the
deep-link URL.
"""
api = _make_api()
assert not hasattr(api, "share")
with pytest.raises(AttributeError):
api.share # type: ignore[attr-defined] # noqa: B018
def test_get_share_url_remains_sync_url_formatter(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("NOTEBOOKLM_BASE_URL", raising=False)
api = _make_api()
url = api.get_share_url("nb_123", artifact_id="art_456")
assert isinstance(url, str)
assert url == "https://notebooklm.google.com/notebook/nb_123?artifactId=art_456"
def _set_account_limit(api: NotebooksAPI, limit: int | None) -> AsyncMock:
mock = AsyncMock(return_value=AccountLimits(notebook_limit=limit))
api._get_account_limits = mock # type: ignore[method-assign]
return mock
class TestCreateNotebookQuotaDetection:
@pytest.mark.asyncio
async def test_create_uses_canonical_payload(self):
# ``create`` now snapshots the notebook list as a baseline
# before issuing CREATE_NOTEBOOK so the probe-then-retry wrapper
# can detect a server-side commit on a transient transport
# failure. Stub ``list`` so the canonical-payload assertion only
# observes the CREATE_NOTEBOOK call.
api = _make_api()
api.list = AsyncMock(return_value=[]) # baseline empty
api._rpc.rpc_call.return_value = [
"Daily News",
None,
"new_notebook_id",
None,
None,
[None, False, None, None, None, [1704067200, 0]],
]
notebook = await api.create("Daily News")
assert notebook.id == "new_notebook_id"
api._rpc.rpc_call.assert_awaited_once_with(
RPCMethod.CREATE_NOTEBOOK,
build_create_notebook_params("Daily News"),
disable_internal_retries=True,
)
@pytest.mark.asyncio
async def test_create_invalid_argument_near_paid_limit_raises_limit_error(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
account_limits = _set_account_limit(api, 500)
api.list = AsyncMock(return_value=_owned_notebooks(499))
with pytest.raises(NotebookLimitError) as exc_info:
await api.create("Daily News")
assert exc_info.value.current_count == 499
assert exc_info.value.limit == 500
assert exc_info.value.original_error is original
assert "499/500" in str(exc_info.value)
account_limits.assert_awaited_once()
# ``create`` calls ``list`` twice on an RPC failure path:
# once for the baseline snapshot, once for the quota check.
assert api.list.await_count == 2
@pytest.mark.asyncio
async def test_create_invalid_argument_at_paid_limit_raises_limit_error(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
_set_account_limit(api, 500)
api.list = AsyncMock(return_value=_owned_notebooks(500))
with pytest.raises(NotebookLimitError) as exc_info:
await api.create("At Paid Limit")
assert exc_info.value.current_count == 500
assert exc_info.value.limit == 500
@pytest.mark.asyncio
async def test_create_invalid_argument_near_free_limit_raises_limit_error(self):
api = _make_api(rpc_call=AsyncMock(side_effect=_create_invalid_argument_error()))
_set_account_limit(api, 100)
api.list = AsyncMock(return_value=_owned_notebooks(100))
with pytest.raises(NotebookLimitError) as exc_info:
await api.create("Free Limit")
assert exc_info.value.current_count == 100
assert exc_info.value.limit == 100
@pytest.mark.asyncio
async def test_create_invalid_argument_uses_account_limit_not_free_boundary(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
_set_account_limit(api, 500)
api.list = AsyncMock(return_value=_owned_notebooks(100))
with pytest.raises(RPCError) as exc_info:
await api.create("Paid Account At Free Boundary")
assert exc_info.value is original
@pytest.mark.asyncio
async def test_create_invalid_argument_away_from_server_limit_preserves_rpc_error(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
_set_account_limit(api, 500)
api.list = AsyncMock(return_value=_owned_notebooks(250))
with pytest.raises(RPCError) as exc_info:
await api.create("Probably Bad Payload")
assert exc_info.value is original
@pytest.mark.asyncio
async def test_non_quota_rpc_code_preserves_rpc_error_without_listing(self):
original = _create_invalid_argument_error(rpc_code=13)
api = _make_api(rpc_call=AsyncMock(side_effect=original))
api._get_account_limits = AsyncMock( # type: ignore[method-assign]
return_value=AccountLimits(notebook_limit=500)
)
api.list = AsyncMock(return_value=_owned_notebooks(500))
with pytest.raises(RPCError) as exc_info:
await api.create("Internal Failure")
assert exc_info.value is original
api._get_account_limits.assert_not_awaited()
# baseline list runs once before CREATE_NOTEBOOK; no
# quota-check list because the RPC code (13) is not the
# quota-exhausted code (3).
assert api.list.await_count == 1
@pytest.mark.asyncio
async def test_non_create_method_preserves_rpc_error_without_listing(self):
original = _create_invalid_argument_error(method_id=RPCMethod.GET_NOTEBOOK.value)
api = _make_api(rpc_call=AsyncMock(side_effect=original))
api._get_account_limits = AsyncMock( # type: ignore[method-assign]
return_value=AccountLimits(notebook_limit=500)
)
api.list = AsyncMock(return_value=_owned_notebooks(500))
with pytest.raises(RPCError) as exc_info:
await api.create("Unexpected Method")
assert exc_info.value is original
api._get_account_limits.assert_not_awaited()
# baseline list runs once before CREATE_NOTEBOOK; no
# quota-check list because the failing method isn't CREATE_NOTEBOOK.
assert api.list.await_count == 1
@pytest.mark.asyncio
async def test_shared_notebooks_do_not_trigger_owned_quota_error(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
_set_account_limit(api, 500)
api.list = AsyncMock(return_value=_owned_notebooks(20) + _shared_notebooks(479))
with pytest.raises(RPCError) as exc_info:
await api.create("Shared Notebooks Should Not Count")
assert exc_info.value is original
@pytest.mark.asyncio
async def test_account_limit_failure_preserves_original_create_error_without_listing(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
api._get_account_limits = AsyncMock( # type: ignore[method-assign]
side_effect=NetworkError("settings failed")
)
api.list = AsyncMock(return_value=_owned_notebooks(500))
with pytest.raises(RPCError) as exc_info:
await api.create("Settings Fails")
assert exc_info.value is original
# only the baseline list runs; the quota-check list is
# skipped because account-limit lookup itself failed.
assert api.list.await_count == 1
@pytest.mark.asyncio
async def test_account_limit_rpc_error_preserves_original_create_error_without_listing(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
api._get_account_limits = AsyncMock( # type: ignore[method-assign]
side_effect=RPCError("settings failed")
)
api.list = AsyncMock(return_value=_owned_notebooks(500))
with pytest.raises(RPCError) as exc_info:
await api.create("Settings RPC Fails")
assert exc_info.value is original
# only the baseline list runs.
assert api.list.await_count == 1
@pytest.mark.asyncio
async def test_missing_account_limit_preserves_original_create_error_without_listing(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
_set_account_limit(api, None)
api.list = AsyncMock(return_value=_owned_notebooks(500))
with pytest.raises(RPCError) as exc_info:
await api.create("No Limit")
assert exc_info.value is original
# only the baseline list runs.
assert api.list.await_count == 1
@pytest.mark.asyncio
async def test_list_failure_preserves_original_create_error(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
_set_account_limit(api, 500)
api.list = AsyncMock(side_effect=NetworkError("list failed"))
with pytest.raises(RPCError) as exc_info:
await api.create("List Fails")
assert exc_info.value is original
@pytest.mark.asyncio
async def test_list_parse_bug_preserves_original_create_error(self):
original = _create_invalid_argument_error()
api = _make_api(rpc_call=AsyncMock(side_effect=original))
_set_account_limit(api, 500)
api.list = AsyncMock(side_effect=ValueError("bad notebook data"))
with pytest.raises(RPCError) as exc_info:
await api.create("List Parse Fails")
assert exc_info.value is original
@pytest.mark.asyncio
async def test_get_account_limits_uses_user_settings_rpc(self):
api = _make_api(rpc_call=AsyncMock(return_value=[[None, [6, 500, 300, 500000, 2]]]))
limits = await api._get_account_limits()
assert limits == AccountLimits(
notebook_limit=500,
source_limit=300,
raw_limits=(6, 500, 300, 500000, 2),
tier=2,
)
api._rpc.rpc_call.assert_awaited_once_with(
RPCMethod.GET_USER_SETTINGS,
[None, [1, None, None, None, None, None, None, None, None, None, [1]]],
source_path="/",
)
class TestGetNotebookFailsClosed:
"""``NotebooksAPI.get`` raises ``NotebookNotFoundError`` on degenerate responses.
The NotebookLM backend returns a *parseable but empty* payload for unknown
notebook IDs rather than a typed error. Pre-fix, ``get()`` happily returned
``Notebook(id="", title="")`` and the CLI ``use`` command persisted that as
saved state. The post-fix contract: detect the degenerate shape and raise.
"""
@pytest.mark.asyncio
async def test_get_returns_notebook_on_full_response(self):
# Realistic shape: [[title, ?, id, ?, ?, [None, False, ...]], ...]
api = _make_api(
rpc_call=AsyncMock(
return_value=[["My Notebook", None, "nb_real_123", None, None, [None, False]]]
)
)
notebook = await api.get("nb_real_123")
assert notebook.id == "nb_real_123"
assert notebook.title == "My Notebook"
@pytest.mark.asyncio
async def test_get_raises_on_empty_outer_list(self):
"""Server returned ``[]`` — no notebook at all."""
api = _make_api(rpc_call=AsyncMock(return_value=[]))
with pytest.raises(NotebookNotFoundError) as exc_info:
await api.get("nb_missing")
assert exc_info.value.notebook_id == "nb_missing"
assert exc_info.value.method_id == RPCMethod.GET_NOTEBOOK.value
@pytest.mark.asyncio
async def test_get_raises_on_none_response(self):
api = _make_api(rpc_call=AsyncMock(return_value=None))
with pytest.raises(NotebookNotFoundError):
await api.get("nb_missing")
@pytest.mark.asyncio
async def test_get_raises_on_degenerate_empty_inner(self):
"""``[[]]`` — outer wrapper present but inner notebook payload empty."""
api = _make_api(rpc_call=AsyncMock(return_value=[[]]))
with pytest.raises(NotebookNotFoundError) as exc_info:
await api.get("nb_typo")
assert "nb_typo" in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_raises_when_id_and_title_both_blank(self):
"""Both id and title parsed to empty string → treat as not found."""
# Same shape as the happy path but with empty strings in both fields.
api = _make_api(
rpc_call=AsyncMock(return_value=[["", None, "", None, None, [None, False]]])
)
with pytest.raises(NotebookNotFoundError):
await api.get("nb_typo")
@pytest.mark.asyncio
async def test_get_succeeds_when_title_present_but_id_blank(self):
"""Defensive: a present title alone is enough — not a degenerate payload.
We only treat the response as "not found" when BOTH id and title are
blank, so a parser-quirk that strips the id but keeps the title still
returns a Notebook rather than raising.
"""
api = _make_api(
rpc_call=AsyncMock(return_value=[["Title Only", None, "", None, None, [None, False]]])
)
notebook = await api.get("nb_partial")
assert notebook.title == "Title Only"
def test_notebook_not_found_error_is_rpc_error(self):
"""``NotebookNotFoundError`` must be catchable as ``RPCError``."""
assert issubclass(NotebookNotFoundError, RPCError)
err = NotebookNotFoundError("nb_x", method_id="rwIQyf")
assert err.notebook_id == "nb_x"
assert err.method_id == "rwIQyf"
class TestListNotebooksPayloadDispatch:
"""``list()`` wrapped-envelope dispatch — absence soft, malformed raises.
Mirrors the ``_artifact/listing.py::list_raw`` fail-loud pattern (#1485):
an empty/``None`` payload and a ``None`` row-list slot are legitimate "no
notebooks" shapes, while a truthy payload that doesn't match the
``[[row, ...]]`` envelope is schema drift — it used to flow garbage rows
into ``Notebook.from_api_response`` and silently fabricate empty-id
notebooks.
"""
@pytest.mark.asyncio
async def test_wrapped_envelope_parses_rows(self):
api = _make_api(
rpc_call=AsyncMock(
return_value=[[["Notebook A", [], "nb_a", "📓"], ["Notebook B", [], "nb_b", "📓"]]]
)
)
notebooks = await api.list()
assert [(nb.id, nb.title) for nb in notebooks] == [
("nb_a", "Notebook A"),
("nb_b", "Notebook B"),
]
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [None, []])
async def test_empty_payload_is_soft_empty(self, payload):
api = _make_api(rpc_call=AsyncMock(return_value=payload))
assert await api.list() == []
@pytest.mark.asyncio
async def test_null_row_list_slot_is_soft_empty(self):
"""A ``None`` where the row list belongs is absence, not drift."""
api = _make_api(rpc_call=AsyncMock(return_value=[None]))
assert await api.list() == []
@pytest.mark.asyncio
async def test_truthy_non_list_payload_raises_decoding_error(self):
from notebooklm.exceptions import DecodingError
api = _make_api(rpc_call=AsyncMock(return_value="garbage"))
with pytest.raises(DecodingError):
await api.list()
@pytest.mark.asyncio
async def test_truthy_non_list_row_slot_raises_decoding_error(self):
"""A moved wrapper (non-list where the row list belongs) is drift."""
from notebooklm.exceptions import DecodingError
api = _make_api(rpc_call=AsyncMock(return_value=["garbage", "rows"]))
with pytest.raises(DecodingError):
await api.list()