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
3002 lines
108 KiB
Python
3002 lines
108 KiB
Python
"""Integration tests for SourcesAPI.
|
|
|
|
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``
|
|
and the various ``sources_*.yaml`` cassettes under ``tests/cassettes/``.
|
|
"""
|
|
|
|
import re
|
|
import urllib.parse
|
|
import zipfile
|
|
from io import BytesIO
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
import notebooklm._sources as _sources_mod
|
|
from notebooklm import NotebookLMClient, Source, SourceType
|
|
from notebooklm._source.add import SourceAddService
|
|
from notebooklm.exceptions import DecodingError, RPCError
|
|
from notebooklm.rpc import RPCMethod
|
|
from notebooklm.types import SourceAddError, SourceNotFoundError
|
|
|
|
pytestmark = pytest.mark.allow_no_vcr
|
|
|
|
|
|
def _add_register_file_source_baseline_mock(httpx_mock: HTTPXMock, build_rpc_response) -> None:
|
|
"""Register an empty GET_NOTEBOOK baseline response for ``add_file`` paths.
|
|
|
|
The ``register_file_source`` wrapper captures a baseline of source IDs
|
|
before the create attempt (see ``_source/upload.py:register_file_source``
|
|
for the rationale — pre-existing same-named sources must NOT match a
|
|
retry probe). For tests that exercise ``add_file`` with a single
|
|
``ADD_SOURCE_FILE`` batchexecute mock, this helper adds the baseline
|
|
GET_NOTEBOOK response (empty notebook) that the new code path requires.
|
|
|
|
Place this BEFORE the test's ``ADD_SOURCE_FILE`` mock so the registered
|
|
responses match the actual request order.
|
|
"""
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*batchexecute.*rpcids=" + RPCMethod.GET_NOTEBOOK.value + r".*"),
|
|
content=build_rpc_response(RPCMethod.GET_NOTEBOOK, [["", []]]).encode(),
|
|
)
|
|
|
|
|
|
class TestAddSource:
|
|
@pytest.mark.asyncio
|
|
async def test_add_source_url(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE,
|
|
[
|
|
[
|
|
[
|
|
["source_id"],
|
|
"Example Site",
|
|
[None, 11, None, None, 5, None, 1, ["https://example.com"]],
|
|
[None, 2],
|
|
]
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.add_url("nb_123", "https://example.com")
|
|
|
|
assert isinstance(source, Source)
|
|
assert source.id == "source_id"
|
|
assert source.url == "https://example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_source_text(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE, [[[["source_id"], "My Document", [None, 11], [None, 2]]]]
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.add_text("nb_123", "My Document", "This is the content")
|
|
|
|
assert isinstance(source, Source)
|
|
assert source.id == "source_id"
|
|
assert source.title == "My Document"
|
|
|
|
|
|
class TestDeleteSource:
|
|
@pytest.mark.asyncio
|
|
async def test_delete_source(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
response = build_rpc_response(RPCMethod.DELETE_SOURCE, [True])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.sources.delete("nb_123", "source_456")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_source_request_format(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
response = build_rpc_response(RPCMethod.DELETE_SOURCE, [True])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
await client.sources.delete("nb_123", "source_456")
|
|
|
|
request = httpx_mock.get_request()
|
|
assert RPCMethod.DELETE_SOURCE in str(request.url)
|
|
assert "source-path=%2Fnotebook%2Fnb_123" in str(request.url)
|
|
|
|
|
|
class TestGetSource:
|
|
@pytest.mark.asyncio
|
|
async def test_get_source(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
# get() filters from get_notebook, so mock GET_NOTEBOOK response
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[
|
|
[
|
|
["source_456"],
|
|
"Source Title",
|
|
[
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
5, # SourceType.WEB_PAGE
|
|
None,
|
|
None,
|
|
["https://example.com"],
|
|
],
|
|
[None, 2], # Status.READY
|
|
]
|
|
],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.get("nb_123", "source_456")
|
|
|
|
assert isinstance(source, Source)
|
|
assert source.id == "source_456"
|
|
assert source.title == "Source Title"
|
|
assert source.kind == SourceType.WEB_PAGE
|
|
assert source.kind == "web_page"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_source_uses_public_list_hook(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
client.sources.list = AsyncMock(
|
|
return_value=[
|
|
Source(id="source_123", title="Other"),
|
|
Source(id="source_456", title="Source Title"),
|
|
]
|
|
)
|
|
|
|
source = await client.sources.get("nb_123", "source_456")
|
|
|
|
client.sources.list.assert_awaited_once_with("nb_123")
|
|
assert source is not None
|
|
assert source.id == "source_456"
|
|
assert source.title == "Source Title"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_uses_current_rpc_executor_call(self, auth_tokens):
|
|
first_rpc = AsyncMock(return_value=[["Notebook", []]])
|
|
second_rpc = AsyncMock(
|
|
return_value=[
|
|
[
|
|
"Notebook",
|
|
[
|
|
[
|
|
["source_456"],
|
|
"Source Title",
|
|
[None, 11, None, None, 5, None, None, ["https://example.com"]],
|
|
[None, 2],
|
|
]
|
|
],
|
|
]
|
|
]
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
client._rpc_executor.rpc_call = first_rpc
|
|
assert await client.sources.list("nb_123") == []
|
|
|
|
client._rpc_executor.rpc_call = second_rpc
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
first_rpc.assert_awaited_once()
|
|
second_rpc.assert_awaited_once()
|
|
assert [source.id for source in sources] == ["source_456"]
|
|
|
|
|
|
class TestSourcesAPI:
|
|
"""Integration tests for SourcesAPI methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing sources with various types."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[
|
|
[
|
|
["src_001"],
|
|
"My Article",
|
|
[
|
|
None,
|
|
11,
|
|
[1704067200, 0],
|
|
None,
|
|
5, # WEB_PAGE type code
|
|
None,
|
|
None,
|
|
["https://example.com"],
|
|
],
|
|
[None, 2],
|
|
],
|
|
[["src_002"], "My Text", [None, 0, [1704153600, 0]], [None, 2]],
|
|
[
|
|
["src_003"],
|
|
"YouTube Video",
|
|
[
|
|
None,
|
|
11,
|
|
[1704240000, 0],
|
|
None,
|
|
9, # YOUTUBE type code
|
|
None,
|
|
None,
|
|
["https://youtube.com/watch?v=abc"],
|
|
],
|
|
[None, 2],
|
|
],
|
|
],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 3
|
|
assert sources[0].id == "src_001"
|
|
assert sources[0].kind == "web_page"
|
|
assert sources[0].url == "https://example.com"
|
|
assert sources[2].kind == "youtube"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_youtube_url_at_index_5(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Regression test for issue #265: YouTube URLs are stored at src[2][5][0].
|
|
|
|
The real NotebookLM API stores YouTube metadata at src[2][5] as
|
|
[url, video_id, channel_name], with src[2][7] = None. The list() method
|
|
must extract the URL from [5] when [7] is not populated.
|
|
"""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[
|
|
[
|
|
["src_yt"],
|
|
"YouTube Video",
|
|
[
|
|
None,
|
|
11,
|
|
[1704240000, 0],
|
|
None,
|
|
9, # YOUTUBE type code
|
|
[
|
|
"https://www.youtube.com/watch?v=dcWU-qD8ISQ",
|
|
"dcWU-qD8ISQ",
|
|
"john newquist",
|
|
],
|
|
None,
|
|
None, # [7] is None for YouTube sources
|
|
],
|
|
[None, 2],
|
|
],
|
|
],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0].id == "src_yt"
|
|
assert sources[0].kind == "youtube"
|
|
assert sources[0].url == "https://www.youtube.com/watch?v=dcWU-qD8ISQ"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_ignores_bare_http_at_index_0(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""metadata[0] must not be used as a URL fallback in list().
|
|
|
|
GET_NOTEBOOK source entries use the same medium-nested shape that
|
|
Source.from_api_response handles with allow_bare_http=False —
|
|
metadata[0] in that shape can pack unrelated http-like data. If the
|
|
bare-[0] fallback fires, list() would surface a bogus URL. Keep
|
|
allow_bare_http=False here so list() and from_api_response agree.
|
|
"""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[
|
|
[
|
|
["src_bare"],
|
|
"Source with bare http at [0]",
|
|
[
|
|
# metadata[0] is an http-like string that must
|
|
# be ignored — it is not a source URL here.
|
|
"http://unrelated.example.com/not-a-source-url",
|
|
11,
|
|
[1704240000, 0],
|
|
None,
|
|
5,
|
|
None,
|
|
None,
|
|
None, # metadata[7] empty → no web-page URL
|
|
],
|
|
[None, 2],
|
|
],
|
|
],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0].id == "src_bare"
|
|
assert sources[0].url is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_index_7_wins_over_5(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""When both src[2][7] and src[2][5] are populated, [7] wins."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[
|
|
[
|
|
["src_both"],
|
|
"Hybrid",
|
|
[
|
|
None,
|
|
11,
|
|
[1704240000, 0],
|
|
None,
|
|
5,
|
|
["https://shouldnt.win/5"],
|
|
None,
|
|
["https://should.win/7"],
|
|
],
|
|
[None, 2],
|
|
],
|
|
],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0].url == "https://should.win/7"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_empty(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing sources from empty notebook."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Empty Notebook",
|
|
[],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert sources == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_source_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test getting a non-existent source raises SourceNotFoundError."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# v0.8.0: a miss now raises SourceNotFoundError (issue #1247).
|
|
with pytest.raises(SourceNotFoundError):
|
|
await client.sources.get("nb_123", "nonexistent")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_drive_source(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test adding a Google Drive source."""
|
|
response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE,
|
|
[[[["drive_001"], "My Doc", [None, 0], [None, 2]]]],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.add_drive(
|
|
"nb_123",
|
|
file_id="abc123xyz",
|
|
title="My Doc",
|
|
mime_type="application/vnd.google-apps.document",
|
|
)
|
|
|
|
assert source is not None
|
|
request = httpx_mock.get_request()
|
|
assert RPCMethod.ADD_SOURCE in str(request.url)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_source(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test refreshing a source."""
|
|
response = build_rpc_response(RPCMethod.REFRESH_SOURCE, None)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.sources.refresh("nb_123", "src_001")
|
|
|
|
assert result is None # v0.8.0 (#1290): returns None on success
|
|
request = httpx_mock.get_request()
|
|
assert RPCMethod.REFRESH_SOURCE in str(request.url)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_fresh(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test checking freshness - source is fresh (explicit True)."""
|
|
response = build_rpc_response("yR9Yof", True)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
is_fresh = await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
assert is_fresh is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_fresh_empty_array(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test checking freshness - source is fresh (empty array response).
|
|
|
|
The real API returns [] (empty array) when source is fresh,
|
|
not True. This test ensures we handle the actual API response.
|
|
"""
|
|
# Real API returns empty array for fresh sources
|
|
response = build_rpc_response("yR9Yof", [])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
is_fresh = await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
assert is_fresh is True, "Empty array should mean source is fresh"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_fresh_drive_nested(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test checking freshness - Drive source is fresh (nested response).
|
|
|
|
Drive sources return [[null, true, [source_id]]] when fresh,
|
|
not an empty array like URL sources.
|
|
"""
|
|
# Real API returns nested structure for Drive sources
|
|
response = build_rpc_response("yR9Yof", [[None, True, ["src_001"]]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
is_fresh = await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
assert is_fresh is True, "Nested [null, true, ...] should mean source is fresh"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_stale(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test checking freshness - source is stale."""
|
|
response = build_rpc_response("yR9Yof", False)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
is_fresh = await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
assert is_fresh is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_guide(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test getting source guide."""
|
|
# Real API returns 3 levels of nesting: [[[null, [summary], [[keywords]], []]]]
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE_GUIDE,
|
|
[
|
|
[
|
|
[
|
|
None,
|
|
["This is a **summary** of the source content..."],
|
|
[["keyword1", "keyword2", "keyword3"]],
|
|
[],
|
|
]
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
guide = await client.sources.get_guide("nb_123", "src_001")
|
|
|
|
# Attribute-only typed return (#1251): the dict-membership bridge was
|
|
# dropped; summary/keywords are plain attributes.
|
|
assert hasattr(guide, "summary")
|
|
assert hasattr(guide, "keywords")
|
|
assert "**summary**" in guide.summary
|
|
assert guide.keywords == ("keyword1", "keyword2", "keyword3")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_guide_empty(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test getting guide for source with no AI analysis."""
|
|
# Real API returns 3 levels of nesting even for empty responses
|
|
response = build_rpc_response(RPCMethod.GET_SOURCE_GUIDE, [[[None, [], [], []]]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
guide = await client.sources.get_guide("nb_123", "src_001")
|
|
|
|
assert guide.summary == ""
|
|
assert guide.keywords == ()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_source_uses_echo(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""When UPDATE_SOURCE echoes the row, rename returns it without a fetch."""
|
|
echo = build_rpc_response(
|
|
RPCMethod.UPDATE_SOURCE,
|
|
[["src_001"], "New Title", [None, None, None, None, 5], [None, 2]],
|
|
)
|
|
httpx_mock.add_response(content=echo.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.rename("nb_123", "src_001", "New Title")
|
|
|
|
assert source is not None
|
|
assert source.id == "src_001"
|
|
assert source.title == "New Title"
|
|
|
|
# Only the UPDATE_SOURCE RPC — the echo path issues no fetch.
|
|
requests = httpx_mock.get_requests()
|
|
assert len(requests) == 1
|
|
assert "b7Wfje" in str(requests[0].url)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_source_falls_back_to_fetch_on_null_echo(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A null UPDATE_SOURCE echo triggers a hydrate fetch returning the server row."""
|
|
# 1) UPDATE_SOURCE echoes nothing.
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.UPDATE_SOURCE.value}.*"),
|
|
content=build_rpc_response(RPCMethod.UPDATE_SOURCE, None).encode(),
|
|
)
|
|
# 2) Hydrate via GET_NOTEBOOK — the server reflects the new title.
|
|
get_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[
|
|
[
|
|
["src_001"],
|
|
"New Title",
|
|
[None, None, None, None, 5, None, None, ["https://example.com"]],
|
|
[None, 2],
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.GET_NOTEBOOK.value}.*"), content=get_response.encode()
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.rename("nb_123", "src_001", "New Title")
|
|
|
|
assert source is not None
|
|
assert source.id == "src_001"
|
|
# Server-reflected, not just echoed back from the input.
|
|
assert source.title == "New Title"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_source_missing_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A null echo plus an absent-from-list source raises SourceNotFoundError."""
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.UPDATE_SOURCE.value}.*"),
|
|
content=build_rpc_response(RPCMethod.UPDATE_SOURCE, None).encode(),
|
|
)
|
|
# Hydrate fetch: notebook exists but the source id is absent.
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.GET_NOTEBOOK.value}.*"),
|
|
content=build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK, [["Test Notebook", [], "nb_123"]]
|
|
).encode(),
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceNotFoundError):
|
|
await client.sources.rename("nb_123", "missing_src", "New Title")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_source_transport_error_not_rewrapped(self, auth_tokens):
|
|
"""A transport failure during hydration propagates, not SourceNotFoundError."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# UPDATE_SOURCE echoes null, then the hydrate fetch blows up.
|
|
rpc = AsyncMock(side_effect=[None, RPCError("boom during hydrate")])
|
|
client._rpc_executor.rpc_call = rpc
|
|
with pytest.raises(RPCError):
|
|
await client.sources.rename("nb_123", "src_001", "New Title")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_source_return_object_false_raises_on_miss(self, auth_tokens):
|
|
"""v0.8.0 (#1362): return_object=False runs the existence preflight too.
|
|
|
|
A null UPDATE_SOURCE echo plus an absent source means the target is
|
|
missing, so the False path raises SourceNotFoundError instead of
|
|
silently returning None.
|
|
"""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
rpc = AsyncMock(return_value=None)
|
|
client._rpc_executor.rpc_call = rpc
|
|
# The existence preflight resolves the source as a genuine miss.
|
|
client.sources._get_or_none = AsyncMock(return_value=None)
|
|
with pytest.raises(SourceNotFoundError):
|
|
await client.sources.rename("nb_123", "src_001", "New Title", return_object=False)
|
|
|
|
|
|
class TestAddFileSource:
|
|
"""Integration tests for file upload functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_file_success(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test successful file upload with 3-step protocol."""
|
|
# Create test file
|
|
test_file = tmp_path / "test_document.txt"
|
|
test_file.write_text("This is test content for upload.")
|
|
|
|
# Step 0: register_file_source captures a baseline GET_NOTEBOOK
|
|
# before the create — see the helper docstring for the rationale.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
# Step 1: Mock RPC registration response (o4cbdc)
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
[[[["file_source_123"], "test_document.txt", [None, None, None, None, 0]]]],
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*batchexecute.*"),
|
|
content=rpc_response.encode(),
|
|
)
|
|
|
|
# Step 2: Mock upload session start response
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0$"),
|
|
headers={
|
|
"x-goog-upload-url": "https://notebooklm.google.com/upload/_/?authuser=0&upload_id=test_upload_id",
|
|
"x-goog-upload-status": "active",
|
|
},
|
|
content=b"",
|
|
)
|
|
|
|
# Step 3: Mock upload finalize response
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0&upload_id=.*"),
|
|
content=b"OK: Enqueued blob bytes to spanner queue for processing.",
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.add_file("nb_123", test_file)
|
|
|
|
assert source is not None
|
|
assert source.id == "file_source_123"
|
|
assert source.title == "test_document.txt"
|
|
assert source.kind == "unknown"
|
|
|
|
# Verify all 4 requests were made: baseline GET_NOTEBOOK + 3-step
|
|
# upload protocol (register + start + finalize).
|
|
requests = httpx_mock.get_requests()
|
|
assert len(requests) == 4
|
|
|
|
# Verify Step 0: baseline GET_NOTEBOOK
|
|
assert RPCMethod.GET_NOTEBOOK in str(requests[0].url)
|
|
|
|
# Verify Step 1: RPC call
|
|
assert RPCMethod.ADD_SOURCE_FILE in str(requests[1].url)
|
|
|
|
# Verify Step 2: Upload start
|
|
assert "x-goog-upload-command" in requests[2].headers
|
|
assert requests[2].headers["x-goog-upload-command"] == "start"
|
|
|
|
# Verify Step 3: Upload finalize (now requests[3] after baseline)
|
|
assert "x-goog-upload-command" in requests[3].headers
|
|
assert requests[3].headers["x-goog-upload-command"] == "upload, finalize"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_file_rpc_params_format(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test that file registration uses correct parameter nesting."""
|
|
test_file = tmp_path / "my_file.pdf"
|
|
test_file.write_bytes(b"%PDF-1.4 fake pdf content")
|
|
|
|
# Baseline GET_NOTEBOOK for the register_file_source probe wrapper.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
# Mock all 3 responses
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
[[[[" src_id"], "my_file.pdf", [None, None, None, None, 0]]]],
|
|
)
|
|
httpx_mock.add_response(url=re.compile(r".*batchexecute.*"), content=rpc_response.encode())
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0$"),
|
|
headers={"x-goog-upload-url": "https://notebooklm.google.com/upload/_/?upload_id=x"},
|
|
)
|
|
httpx_mock.add_response(url=re.compile(r".*upload_id=.*"), content=b"OK")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
await client.sources.add_file("nb_123", test_file)
|
|
|
|
# Check the RPC request body contains correct nesting
|
|
# params[0] should be [[filename]] (double-nested within the param)
|
|
# In the full params array JSON: [[[filename]], nb_id, ...] (3 brackets total)
|
|
# NOT [[[[filename]]], ...] (4 brackets - the old bug)
|
|
# Index [1] is the ADD_SOURCE_FILE request (index [0] is the
|
|
# baseline GET_NOTEBOOK from the probe-then-create wrapper).
|
|
rpc_request = httpx_mock.get_requests()[1]
|
|
body = urllib.parse.unquote(rpc_request.content.decode())
|
|
# The params are JSON-encoded inside the RPC wrapper, so quotes are escaped
|
|
# Verify 3 brackets (correct) not 4 brackets (bug)
|
|
assert '[[[\\"my_file.pdf\\"]]' in body, f"Expected 3 brackets, got: {body}"
|
|
assert '[[[[\\"my_file.pdf\\"]]' not in body, "Should not have 4 brackets (old bug)"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_file_not_found(
|
|
self,
|
|
auth_tokens,
|
|
tmp_path,
|
|
):
|
|
"""Test file upload with non-existent file."""
|
|
nonexistent = tmp_path / "does_not_exist.txt"
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(FileNotFoundError):
|
|
await client.sources.add_file("nb_123", nonexistent)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_file_upload_metadata(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test that upload session includes correct metadata."""
|
|
test_file = tmp_path / "document.txt"
|
|
content = "Test content " * 100
|
|
test_file.write_text(content)
|
|
|
|
# Baseline GET_NOTEBOOK for the register_file_source probe wrapper.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
[[[["src_abc"], "document.txt", [None, None, None, None, 0]]]],
|
|
)
|
|
httpx_mock.add_response(url=re.compile(r".*batchexecute.*"), content=rpc_response.encode())
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0$"),
|
|
headers={"x-goog-upload-url": "https://notebooklm.google.com/upload/_/?upload_id=y"},
|
|
)
|
|
httpx_mock.add_response(url=re.compile(r".*upload_id=.*"), content=b"OK")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
await client.sources.add_file("nb_123", test_file)
|
|
|
|
# Check upload start request (Step 2). Request indices: [0]
|
|
# baseline GET_NOTEBOOK, [1] ADD_SOURCE_FILE register, [2] upload
|
|
# start, [3] upload finalize.
|
|
start_request = httpx_mock.get_requests()[2]
|
|
|
|
# Verify headers
|
|
assert start_request.headers["x-goog-upload-protocol"] == "resumable"
|
|
assert start_request.headers["x-goog-upload-header-content-length"] == str(len(content))
|
|
|
|
# Verify body contains metadata
|
|
import json
|
|
|
|
body = json.loads(start_request.content.decode())
|
|
assert body["PROJECT_ID"] == "nb_123"
|
|
assert body["SOURCE_NAME"] == "document.txt"
|
|
assert body["SOURCE_ID"] == "src_abc"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_file_content_upload(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test that file content is correctly uploaded."""
|
|
test_file = tmp_path / "binary_file.bin"
|
|
binary_content = b"\x00\x01\x02\x03\xff\xfe\xfd"
|
|
test_file.write_bytes(binary_content)
|
|
|
|
# Baseline GET_NOTEBOOK for the register_file_source probe wrapper.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
[[[["src_bin"], "binary_file.bin", [None, None, None, None, 0]]]],
|
|
)
|
|
httpx_mock.add_response(url=re.compile(r".*batchexecute.*"), content=rpc_response.encode())
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0$"),
|
|
headers={"x-goog-upload-url": "https://notebooklm.google.com/upload/_/?upload_id=z"},
|
|
)
|
|
httpx_mock.add_response(url=re.compile(r".*upload_id=.*"), content=b"OK")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
await client.sources.add_file("nb_123", test_file)
|
|
|
|
# Check upload content request (Step 3 — finalize POST). Request
|
|
# indices: [0] baseline GET_NOTEBOOK, [1] ADD_SOURCE_FILE register,
|
|
# [2] upload start, [3] upload finalize.
|
|
upload_request = httpx_mock.get_requests()[3]
|
|
|
|
# Verify the actual content was sent
|
|
assert upload_request.content == binary_content
|
|
assert upload_request.headers["x-goog-upload-offset"] == "0"
|
|
|
|
|
|
class TestAddEpubFileSource:
|
|
"""Integration tests for EPUB file upload."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_epub_file_upload(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test EPUB file upload through the 3-step protocol."""
|
|
# Minimal EPUB ZIP — only needs valid ZIP structure since the server
|
|
# is mocked and never processes the content.
|
|
test_epub = tmp_path / "test_book.epub"
|
|
buffer = BytesIO()
|
|
with zipfile.ZipFile(buffer, "w") as zf:
|
|
zf.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
|
|
zf.writestr("OEBPS/chapter1.xhtml", "<html><body><p>Test</p></body></html>")
|
|
test_epub.write_bytes(buffer.getvalue())
|
|
|
|
# Step 0: Baseline GET_NOTEBOOK for the register_file_source probe wrapper.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
# Step 1: Mock RPC registration
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
[[[["epub_source_123"], "test_book.epub", [None, None, None, None, 0]]]],
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*batchexecute.*"),
|
|
content=rpc_response.encode(),
|
|
)
|
|
|
|
# Step 2: Mock upload session start
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0$"),
|
|
headers={
|
|
"x-goog-upload-url": "https://notebooklm.google.com/upload/_/?authuser=0&upload_id=epub_upload",
|
|
"x-goog-upload-status": "active",
|
|
},
|
|
content=b"",
|
|
)
|
|
|
|
# Step 3: Mock upload finalize
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0&upload_id=.*"),
|
|
content=b"OK: Enqueued blob bytes to spanner queue for processing.",
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# Omit an explicit MIME type here so the upload pipeline exercises
|
|
# filename-extension inference for EPUB files.
|
|
source = await client.sources.add_file(
|
|
"nb_123",
|
|
test_epub,
|
|
)
|
|
|
|
assert source.id == "epub_source_123"
|
|
assert source.title == "test_book.epub"
|
|
|
|
# Verify all 4 requests were made: baseline GET_NOTEBOOK +
|
|
# 3-step upload protocol (register + start + finalize).
|
|
requests = httpx_mock.get_requests()
|
|
assert len(requests) == 4
|
|
|
|
# Verify uploaded content is the EPUB ZIP bytes (now requests[3]
|
|
# after the baseline shifted indices).
|
|
upload_request = requests[3]
|
|
assert upload_request.content == test_epub.read_bytes()
|
|
|
|
|
|
class TestGetFulltext:
|
|
"""Tests for sources.get_fulltext() method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_basic(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test getting fulltext content of a source."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
[
|
|
"source_123",
|
|
"My Article",
|
|
[None, None, None, None, 5, None, None, ["https://example.com"]],
|
|
],
|
|
None,
|
|
None,
|
|
[
|
|
[
|
|
[0, 100, "This is the first paragraph of the article."],
|
|
[100, 200, "This is the second paragraph."],
|
|
]
|
|
],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "source_123")
|
|
|
|
from notebooklm import SourceFulltext
|
|
|
|
assert isinstance(fulltext, SourceFulltext)
|
|
assert fulltext.source_id == "source_123"
|
|
assert fulltext.title == "My Article"
|
|
assert fulltext.kind == SourceType.WEB_PAGE
|
|
assert fulltext.url == "https://example.com"
|
|
assert "first paragraph" in fulltext.content
|
|
assert "second paragraph" in fulltext.content
|
|
assert fulltext.char_count > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_request_format(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test that get_fulltext sends correct RPC request."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[["src_456", "Title", []], None, None, [[["Content here"]]]],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
await client.sources.get_fulltext("nb_123", "src_456")
|
|
|
|
request = httpx_mock.get_request()
|
|
# Verify RPC method in URL
|
|
assert RPCMethod.GET_SOURCE in str(request.url)
|
|
# Verify source_path includes notebook_id
|
|
assert "source-path=%2Fnotebook%2Fnb_123" in str(request.url)
|
|
# Verify params format: [[source_id], [2], [2]]
|
|
body = urllib.parse.unquote(request.content.decode())
|
|
assert "src_456" in body
|
|
# Check for the [2], [2] structure
|
|
assert "[2]" in body
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_empty_content(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext with empty content."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[["src_empty", "Empty Source", []], None, None, None],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_empty")
|
|
|
|
assert fulltext.source_id == "src_empty"
|
|
assert fulltext.title == "Empty Source"
|
|
assert fulltext.content == ""
|
|
assert fulltext.char_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_markdown_basic(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Markdown mode converts HTML at result[4][1] via markdownify."""
|
|
pytest.importorskip("markdownify")
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
["source_md", "MD Article", []],
|
|
None,
|
|
None,
|
|
None,
|
|
[None, "<h1>Title</h1><p>Hello <strong>world</strong>.</p>"],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext(
|
|
"nb_123", "source_md", output_format="markdown"
|
|
)
|
|
|
|
assert fulltext.source_id == "source_md"
|
|
assert "# Title" in fulltext.content
|
|
assert "**world**" in fulltext.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_markdown_sends_params_3_3(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Markdown mode requests HTML rendition via params [3],[3]."""
|
|
pytest.importorskip("markdownify")
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[["src_x", "T", []], None, None, None, [None, "<p>x</p>"]],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
await client.sources.get_fulltext("nb_123", "src_x", output_format="markdown")
|
|
|
|
body = urllib.parse.unquote(httpx_mock.get_request().content.decode())
|
|
assert "[3]" in body
|
|
assert "[2]" not in body or body.count("[3]") >= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_invalid_format_raises(self, auth_tokens):
|
|
"""Unknown output_format must fail fast before any RPC call."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ValueError, match=r"text.*markdown"):
|
|
await client.sources.get_fulltext(
|
|
"nb_123",
|
|
"src_x",
|
|
output_format="html", # type: ignore[arg-type]
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_markdown_missing_dep_raises(self, monkeypatch, auth_tokens):
|
|
"""When markdownify is unavailable, ImportError surfaces before the RPC."""
|
|
import builtins
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name == "markdownify":
|
|
raise ImportError("No module named 'markdownify'")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ImportError, match="markdownify"):
|
|
await client.sources.get_fulltext("nb_123", "src_x", output_format="markdown")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_markdown_no_html_returns_empty(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
caplog,
|
|
):
|
|
"""Source with no HTML rendition logs a warning and returns empty content."""
|
|
pytest.importorskip("markdownify")
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[["src_yt", "YouTube vid", []], None, None, None],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with caplog.at_level("WARNING"):
|
|
fulltext = await client.sources.get_fulltext(
|
|
"nb_123", "src_yt", output_format="markdown"
|
|
)
|
|
|
|
assert fulltext.content == ""
|
|
assert any("no HTML rendition" in r.message for r in caplog.records)
|
|
|
|
|
|
class TestListSourcesMalformedResponse:
|
|
"""Tests for list() warning paths when API response is malformed (lines 71-95)."""
|
|
|
|
# Strict decoding is the only mode (the ``NOTEBOOKLM_STRICT_DECODE=0``
|
|
# soft-mode opt-out was retired in v0.7.0): every malformed/error-shaped
|
|
# GET_NOTEBOOK response raises ``RPCError`` rather than silently returning
|
|
# ``[]`` (issue #1159).
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_empty_response_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""An empty notebook response raises (lines 71-76)."""
|
|
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, [])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(RPCError):
|
|
await client.sources.list("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_non_list_notebook_entry_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A non-list ``notebook[0]`` raises (lines 80-85)."""
|
|
# notebook[0] is a string, not a list - fails isinstance(nb_info, list)
|
|
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, ["just_a_string"])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(RPCError):
|
|
await client.sources.list("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_nb_info_missing_index_1_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A too-short ``notebook[0]`` (no index 1) raises (lines 80-85)."""
|
|
# notebook[0] is a list with only 1 element - len(nb_info) <= 1
|
|
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, [["just_a_title"]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(RPCError):
|
|
await client.sources.list("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_sources_list_not_a_list_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A non-list ``nb_info[1]`` raises (lines 89-95)."""
|
|
# nb_info[1] is a string - fails isinstance(sources_list, list)
|
|
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, [["Notebook Title", "not_a_list"]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(RPCError):
|
|
await client.sources.list("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("rpc_result", "message"),
|
|
[
|
|
([], "API response structure changed"),
|
|
(["just_a_string"], "API response structure changed"),
|
|
([["just_a_title"]], "API response structure changed"),
|
|
([["Notebook Title", "not_a_list"]], "sources data is str, not list"),
|
|
],
|
|
)
|
|
async def test_list_sources_strict_raises_for_malformed_response(
|
|
self,
|
|
rpc_result,
|
|
message,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Strict mode exposes malformed source-list responses to callers
|
|
that must distinguish API-structure failures from empty notebooks.
|
|
"""
|
|
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, rpc_result)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(RPCError, match=message):
|
|
await client.sources.list("nb_123", strict=True)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("rpc_result", "message"),
|
|
[
|
|
([], "API response structure changed"),
|
|
(["just_a_string"], "API response structure changed"),
|
|
([["just_a_title"]], "API response structure changed"),
|
|
([["Notebook Title", "not_a_list"]], "sources data is str, not list"),
|
|
],
|
|
)
|
|
async def test_list_sources_raises_by_default_under_strict_decode(
|
|
self,
|
|
rpc_result,
|
|
message,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Regression for #1159: a malformed source-list response must raise
|
|
by default (strict-decode ON), not silently report "0 sources".
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_STRICT_DECODE", raising=False)
|
|
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, rpc_result)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(RPCError, match=message):
|
|
await client.sources.list("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_null_slot_is_empty_notebook_under_strict_decode(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""A genuinely empty notebook elides the sources slot as ``None``;
|
|
this is a valid empty state (#1159) and must return ``[]`` even under
|
|
default strict-decode — never raise.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_STRICT_DECODE", raising=False)
|
|
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, [["Notebook Title", None, "nb_123"]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert sources == []
|
|
|
|
|
|
class TestListSourcesParsingEdgeCases:
|
|
"""Tests for list() per-source parsing edge cases (lines 100-143)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_src_id_not_nested(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test list() uses src[0] directly when src[0] is not a list (line 102)."""
|
|
# src[0] is a plain string (not a list), so src_id = src[0] directly
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook Title",
|
|
[["plain_src_id", "Source Title", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0].id == "plain_src_id"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_url_list_empty(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test list() handles empty URL list at src[2][7] - url stays None (lines 109->113)."""
|
|
# src[2][7] is an empty list - URL should remain None
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook Title",
|
|
[
|
|
[
|
|
["src_001"],
|
|
"Source Title",
|
|
[None, 11, [1704067200, 0], None, 5, None, None, []],
|
|
[None, 2],
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0].url is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_timestamp_invalid(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test list() handles invalid timestamp gracefully (lines 119-120)."""
|
|
# timestamp_list[0] is None, causing TypeError in datetime.fromtimestamp
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook Title",
|
|
[
|
|
[
|
|
["src_001"],
|
|
"Source Title",
|
|
[None, 11, [None], None, 5],
|
|
[None, 2],
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0].created_at is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_invalid_status_code(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test list() uses default READY when status_code is unknown (lines 125->137, 127->137)."""
|
|
# status_code 999 is not in the SourceStatus enum values
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook Title",
|
|
[
|
|
[
|
|
["src_001"],
|
|
"Source Title",
|
|
[None, 11, [1704067200, 0], None, 5],
|
|
[None, 999],
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
from notebooklm.rpc.types import SourceStatus
|
|
|
|
assert sources[0].status == SourceStatus.READY
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_type_code_not_int(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test list() skips type_code when src[2][4] is not an int (lines 140->143)."""
|
|
# src[2][4] is a string - type_code should stay None
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook Title",
|
|
[
|
|
[
|
|
["src_001"],
|
|
"Source Title",
|
|
[None, 11, [1704067200, 0], None, "not_an_int"],
|
|
[None, 2],
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0]._type_code is None
|
|
|
|
|
|
class TestWaitForSources:
|
|
"""Tests for wait_for_sources() parallel waiting (lines 278-281, 455)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_sources_parallel(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test wait_for_sources() calls wait_until_ready for each source in parallel."""
|
|
ready_source_1 = Source(id="src_1", title="Source 1")
|
|
ready_source_2 = Source(id="src_2", title="Source 2")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
call_count = 0
|
|
|
|
async def mock_wait(notebook_id, source_id, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if source_id == "src_1":
|
|
return ready_source_1
|
|
return ready_source_2
|
|
|
|
with patch.object(client.sources, "wait_until_ready", side_effect=mock_wait):
|
|
results = await client.sources.wait_for_sources("nb_123", ["src_1", "src_2"])
|
|
|
|
assert len(results) == 2
|
|
assert call_count == 2
|
|
assert results[0].id == "src_1"
|
|
assert results[1].id == "src_2"
|
|
|
|
|
|
class TestAddUrlErrorPaths:
|
|
"""Tests for add_url() error paths (lines 320, 327-329, 332, 336)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_url_rpc_error_raises_source_add_error(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test add_url() wraps RPCError from the regular URL adder in SourceAddError."""
|
|
service = SourceAddService()
|
|
|
|
with pytest.raises(SourceAddError):
|
|
await service.add_url(
|
|
"nb_123",
|
|
"https://example.com",
|
|
add_youtube_source=AsyncMock(),
|
|
add_url_source=AsyncMock(side_effect=RPCError("RPC call failed")),
|
|
list_sources=AsyncMock(return_value=[]),
|
|
wait_until_ready=AsyncMock(),
|
|
extract_youtube_video_id=lambda _url: None,
|
|
is_youtube_url=lambda _url: False,
|
|
logger=_sources_mod.logger,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_url_youtube_rpc_error_raises_source_add_error(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test add_url() wraps RPCError from the YouTube adder in SourceAddError."""
|
|
service = SourceAddService()
|
|
|
|
with pytest.raises(SourceAddError):
|
|
await service.add_url(
|
|
"nb_123",
|
|
"https://youtube.com/watch?v=dQw4w9WgXcQ",
|
|
add_youtube_source=AsyncMock(side_effect=RPCError("YouTube RPC failed")),
|
|
add_url_source=AsyncMock(),
|
|
list_sources=AsyncMock(return_value=[]),
|
|
wait_until_ready=AsyncMock(),
|
|
extract_youtube_video_id=lambda _url: "dQw4w9WgXcQ",
|
|
is_youtube_url=lambda _url: True,
|
|
logger=_sources_mod.logger,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_url_none_result_raises_source_add_error(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test add_url() raises SourceAddError when API returns None."""
|
|
service = SourceAddService()
|
|
|
|
with pytest.raises(SourceAddError, match="API returned no data"):
|
|
await service.add_url(
|
|
"nb_123",
|
|
"https://example.com",
|
|
add_youtube_source=AsyncMock(),
|
|
add_url_source=AsyncMock(return_value=None),
|
|
list_sources=AsyncMock(return_value=[]),
|
|
wait_until_ready=AsyncMock(),
|
|
extract_youtube_video_id=lambda _url: None,
|
|
is_youtube_url=lambda _url: False,
|
|
logger=_sources_mod.logger,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_url_wait_true(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test add_url() with wait=True calls wait_until_ready (lines 335-336)."""
|
|
source_data = [[[["src_wait_url"], "Example", [None, 11], [None, 2]]]]
|
|
ready_source = Source(id="src_wait_url", title="Example")
|
|
response = build_rpc_response(RPCMethod.ADD_SOURCE, source_data)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources,
|
|
"wait_until_ready",
|
|
new_callable=AsyncMock,
|
|
return_value=ready_source,
|
|
) as mock_wait:
|
|
result = await client.sources.add_url("nb_123", "https://example.com", wait=True)
|
|
|
|
mock_wait.assert_called_once()
|
|
assert result.id == "src_wait_url"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_url_youtube_like_no_id_warning(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test add_url() warns when URL looks like YouTube but has no video ID (line 320)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE,
|
|
[[[["src_channel"], "YouTube Channel", [None, 11], [None, 2]]]],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(_sources_mod, "is_youtube_url", return_value=True) as mock_is_yt:
|
|
source = await client.sources.add_url(
|
|
"nb_123", "https://youtube.com/channel/UCxxxxxxx"
|
|
)
|
|
|
|
assert source is not None
|
|
mock_is_yt.assert_called_once()
|
|
|
|
|
|
class TestAddTextErrorPaths:
|
|
"""Tests for add_text() error paths (lines 374-375, 382, 387)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_text_rpc_error_raises_source_add_error(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test add_text() wraps RPCError in SourceAddError (lines 374-375)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources._rpc,
|
|
"rpc_call",
|
|
side_effect=RPCError("Text RPC failed"),
|
|
):
|
|
with pytest.raises(SourceAddError, match="Failed to add text source"):
|
|
await client.sources.add_text("nb_123", "My Title", "content")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_text_none_result_raises_source_add_error(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test add_text() raises SourceAddError when API returns None (line 382)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources._rpc,
|
|
"rpc_call",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
):
|
|
with pytest.raises(SourceAddError, match="API returned no data"):
|
|
await client.sources.add_text("nb_123", "My Title", "content")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_text_wait_true(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test add_text() with wait=True calls wait_until_ready (line 387)."""
|
|
source_data = [[[["src_wait_text"], "My Title", [None, 0], [None, 2]]]]
|
|
ready_source = Source(id="src_wait_text", title="My Title")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources._rpc,
|
|
"rpc_call",
|
|
new_callable=AsyncMock,
|
|
return_value=source_data,
|
|
):
|
|
with patch.object(
|
|
client.sources,
|
|
"wait_until_ready",
|
|
new_callable=AsyncMock,
|
|
return_value=ready_source,
|
|
) as mock_wait:
|
|
result = await client.sources.add_text(
|
|
"nb_123", "My Title", "content", wait=True
|
|
)
|
|
|
|
mock_wait.assert_called_once()
|
|
assert result.id == "src_wait_text"
|
|
|
|
|
|
class TestAddFileWait:
|
|
"""Tests for add_file() with wait=True (lines 454-455)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_file_wait_true(
|
|
self,
|
|
auth_tokens,
|
|
tmp_path,
|
|
):
|
|
"""Test add_file() with wait=True calls wait_until_ready (lines 454-455)."""
|
|
test_file = tmp_path / "test.pdf"
|
|
test_file.write_bytes(b"%PDF-1.4 fake content")
|
|
|
|
ready_source = Source(id="file_src_001", title="test.pdf")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources._uploader,
|
|
"register_file_source",
|
|
new_callable=AsyncMock,
|
|
return_value="file_src_001",
|
|
):
|
|
with patch.object(
|
|
client.sources._uploader,
|
|
"start_resumable_upload",
|
|
new_callable=AsyncMock,
|
|
return_value="https://notebooklm.google.com/upload/_/?upload_id=abc",
|
|
):
|
|
with patch.object(
|
|
client.sources._uploader,
|
|
"upload_file_streaming",
|
|
new_callable=AsyncMock,
|
|
):
|
|
with patch.object(
|
|
client.sources._uploader,
|
|
"wait_until_ready",
|
|
new_callable=AsyncMock,
|
|
return_value=ready_source,
|
|
) as mock_wait:
|
|
result = await client.sources.add_file("nb_123", test_file, wait=True)
|
|
|
|
mock_wait.assert_called_once_with(
|
|
"nb_123", "file_src_001", timeout=120.0, transient_error_types=()
|
|
)
|
|
assert result.id == "file_src_001"
|
|
|
|
|
|
class TestAddDriveWait:
|
|
"""Tests for add_drive() with wait=True (line 526)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_drive_wait_true(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test add_drive() with wait=True calls wait_until_ready (line 526)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE,
|
|
[[[["drive_src_wait"], "My Drive Doc", [None, 0], [None, 2]]]],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
ready_source = Source(id="drive_src_wait", title="My Drive Doc")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources,
|
|
"wait_until_ready",
|
|
new_callable=AsyncMock,
|
|
return_value=ready_source,
|
|
) as mock_wait:
|
|
result = await client.sources.add_drive(
|
|
"nb_123",
|
|
file_id="drive_file_abc",
|
|
title="My Drive Doc",
|
|
wait=True,
|
|
)
|
|
|
|
mock_wait.assert_called_once()
|
|
assert result.id == "drive_src_wait"
|
|
|
|
|
|
class TestCheckFreshnessEdgeCases:
|
|
"""check_freshness() shape handling: recognized shapes yield a bool;
|
|
genuinely-unrecognized shapes raise DecodingError (drift, #1344)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_none_result_raises_decoding_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""None is not a recognized freshness signal -> drift (#1344)."""
|
|
# None is not True/False/list - structurally unrecognized, so we can't
|
|
# tell fresh from stale: raise rather than silently report "stale".
|
|
response = build_rpc_response(RPCMethod.CHECK_SOURCE_FRESHNESS, None)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(DecodingError):
|
|
await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_list_first_element_not_list_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A list whose first element is a non-list scalar is drift (#1344)."""
|
|
# result is ["some_value"] - first is a string, not a nested freshness
|
|
# row: the canonical "genuinely unrecognized" shape.
|
|
response = build_rpc_response(RPCMethod.CHECK_SOURCE_FRESHNESS, ["some_value"])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(DecodingError):
|
|
await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_drive_nested_false_value(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Recognized stale Drive shape [[None, False, ...]] still returns False."""
|
|
# result is [[None, False, ...]] - first[1] is False: a recognized stale
|
|
# shape, NOT drift. Must stay False (does not raise).
|
|
response = build_rpc_response(
|
|
RPCMethod.CHECK_SOURCE_FRESHNESS, [[None, False, ["src_001"]]]
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
is_fresh = await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
assert is_fresh is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_freshness_drive_nested_list_too_short_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A nested list too short to carry the freshness flag is drift (#1344)."""
|
|
# result is [[None]] - first has only 1 element, so we can't read the
|
|
# freshness flag at first[1]: unrecognized shape -> raise.
|
|
response = build_rpc_response(RPCMethod.CHECK_SOURCE_FRESHNESS, [[None]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(DecodingError):
|
|
await client.sources.check_freshness("nb_123", "src_001")
|
|
|
|
|
|
class TestGetFulltextEdgeCases:
|
|
"""Tests for get_fulltext() URL/type parsing and empty content (lines 700, 708->732, 764-765)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_with_source_type_and_url(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() parses source_type and url from result[0][2] (lines 708-732)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
[
|
|
"src_123",
|
|
"Web Article",
|
|
# result[0][2]: index 4=source_type(5), index 7=[url]
|
|
[None, 0, None, None, 5, None, 1, ["https://example.com/article"]],
|
|
],
|
|
None,
|
|
None,
|
|
[[["The main content of the article."]]],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_123")
|
|
|
|
assert fulltext.title == "Web Article"
|
|
assert fulltext.url == "https://example.com/article"
|
|
assert fulltext._type_code == 5
|
|
assert "main content" in fulltext.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_youtube_url_at_index_5(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Regression: YouTube fulltext metadata stores URL at result[0][2][5][0]."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
[
|
|
"src_yt",
|
|
"YouTube Video",
|
|
[
|
|
None,
|
|
0,
|
|
None,
|
|
None,
|
|
9,
|
|
[
|
|
"https://www.youtube.com/watch?v=dcWU-qD8ISQ",
|
|
"dcWU-qD8ISQ",
|
|
"john newquist",
|
|
],
|
|
None,
|
|
None,
|
|
],
|
|
],
|
|
None,
|
|
None,
|
|
[[["Transcript content."]]],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_yt")
|
|
|
|
assert fulltext._type_code == 9
|
|
assert fulltext.url == "https://www.youtube.com/watch?v=dcWU-qD8ISQ"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_source_type_only_empty_url_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() parses type_code when result[0][2][7] is empty list (lines 714->725, 720->725)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
[
|
|
"src_pdf",
|
|
"PDF Document",
|
|
# result[0][2]: index 4=3 (PDF), index 7=[] (empty url list)
|
|
[None, 0, None, None, 3, None, None, []],
|
|
],
|
|
None,
|
|
None,
|
|
[[["PDF text content here."]]],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_pdf")
|
|
|
|
assert fulltext._type_code == 3
|
|
assert fulltext.url is None
|
|
assert "PDF text content" in fulltext.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_no_content_logs_warning(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() logs warning when content is empty (lines 764-765 via 731-738)."""
|
|
# result[3][0] is an empty list -> texts = [] -> content = ""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
["src_no_content", "No Content Source", [None, 0, None, None, 4]],
|
|
None,
|
|
None,
|
|
[[]],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
import logging
|
|
|
|
with patch.object(
|
|
logging.getLogger("notebooklm._sources"),
|
|
"warning",
|
|
) as mock_warn:
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_no_content")
|
|
|
|
assert fulltext.content == ""
|
|
assert fulltext.char_count == 0
|
|
mock_warn.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_result_not_list_raises_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() raises SourceNotFoundError when result is None (line 700)."""
|
|
response = build_rpc_response(RPCMethod.GET_SOURCE, None)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceNotFoundError):
|
|
await client.sources.get_fulltext("nb_123", "missing_src")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_result_0_2_short_no_type(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() when result[0][2] has fewer than 5 elements - no type_code (lines 710->725, 714->725)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
# result[0][2] has only 3 elements - no index 4 for type_code
|
|
["src_short", "Short Meta", [None, 0, None]],
|
|
None,
|
|
None,
|
|
[[["Some content."]]],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_short")
|
|
|
|
assert fulltext._type_code is None
|
|
assert fulltext.url is None
|
|
assert "Some content" in fulltext.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_result_0_2_has_type_no_url_field(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() when result[0][2] has type at [4] but no [7] (lines 719-720 branch)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
# result[0][2] has 6 elements - type at [4] but no [7]
|
|
["src_no_url", "No URL Source", [None, 0, None, None, 9, None]],
|
|
None,
|
|
None,
|
|
[[["YouTube content here."]]],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_no_url")
|
|
|
|
assert fulltext._type_code == 9
|
|
assert fulltext.url is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_result3_missing(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() when result has fewer than 4 elements - no content (lines 727->732 branch not taken)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
# Only 2 elements - no result[3]
|
|
[
|
|
["src_no_r3", "No Result3", [None, 0, None, None, 4]],
|
|
None,
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_no_r3")
|
|
|
|
assert fulltext.content == ""
|
|
|
|
|
|
class TestExtractAllText:
|
|
"""Tests for _extract_all_text() max_depth guard (lines 763-765)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_all_text_max_depth_zero_returns_empty(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_all_text() returns [] when max_depth=0 (lines 763-765)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_all_text(["some", "text"], max_depth=0)
|
|
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_all_text_nested_lists(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_all_text() recursively extracts text from nested arrays."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_all_text([["hello", ["world"]], "foo", [[], "bar"]])
|
|
|
|
assert result == ["hello", "world", "foo", "bar"]
|
|
|
|
|
|
class TestExtractYoutubeVideoId:
|
|
"""Tests for _extract_youtube_video_id() edge cases (lines 810-819, 832-852)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_youtu_be_no_path(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() returns None for youtu.be with empty path (line 836)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id("https://youtu.be/")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_non_youtube_domain(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() returns None for non-YouTube URL."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id("https://example.com/video")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_invalid_id_chars(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() returns None when video ID has invalid chars (line 812->815)."""
|
|
# URL with invalid video ID characters that fail _is_valid_video_id
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id(
|
|
"https://youtube.com/watch?v=invalid id!"
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_parse_error(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() handles exceptions gracefully (lines 817-819)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# Patching urlparse to raise ValueError covers the except block
|
|
with patch.object(
|
|
_sources_mod, "urlparse", side_effect=ValueError("parse error")
|
|
) as mock_urlparse:
|
|
result = client.sources._extract_youtube_video_id(
|
|
"https://youtube.com/watch?v=abc123"
|
|
)
|
|
|
|
mock_urlparse.assert_called_once()
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_watch_url(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() extracts ID from standard watch URL (lines 845-850)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id(
|
|
"https://youtube.com/watch?v=dQw4w9WgXcQ"
|
|
)
|
|
|
|
assert result == "dQw4w9WgXcQ"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_no_query_param(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() returns None for youtube.com URL without v param (line 852)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id(
|
|
"https://youtube.com/playlist?list=PLxxxxxxxx"
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestListSourcesSkippedEntries:
|
|
"""Tests for list() skipping non-list source entries (line 100->99)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_skips_non_list_entry(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test list() skips source entries that are not lists (line 100->99)."""
|
|
# Mix of valid and invalid source entries
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook Title",
|
|
[
|
|
# Valid source entry
|
|
[["src_001"], "Valid Source", [None, 0], [None, 2]],
|
|
# Non-list entry - should be skipped
|
|
"not_a_list",
|
|
# Another non-list - should be skipped
|
|
None,
|
|
# Valid source entry
|
|
[["src_002"], "Also Valid", [None, 0], [None, 2]],
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 2
|
|
assert sources[0].id == "src_001"
|
|
assert sources[1].id == "src_002"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sources_no_status_data(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test list() defaults to READY when src has no index 3 (line 125->137 false branch)."""
|
|
from notebooklm.rpc.types import SourceStatus
|
|
|
|
# Source with only 2 elements - no status data at index 3
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook Title",
|
|
[
|
|
[["src_001"], "No Status Source"],
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
sources = await client.sources.list("nb_123")
|
|
|
|
assert len(sources) == 1
|
|
assert sources[0].status == SourceStatus.READY
|
|
|
|
|
|
class TestWaitUntilReady:
|
|
"""Tests for wait_until_ready() implementation (lines 214-244)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_source_ready_immediately(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test wait_until_ready() returns immediately when source is already READY (line 231-232)."""
|
|
# Mock GET_NOTEBOOK to return a source with READY status (status_code=2)
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook",
|
|
[
|
|
[
|
|
["src_ready"],
|
|
"Ready Source",
|
|
[None, 0],
|
|
[None, 2], # status=READY
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.wait_until_ready("nb_123", "src_ready")
|
|
|
|
assert source.id == "src_ready"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_source_not_found_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test wait_until_ready() raises SourceNotFoundError when source is None (line 226-227)."""
|
|
# Mock GET_NOTEBOOK to return no sources (so get() returns None)
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[["Notebook", [], "nb_123"]],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceNotFoundError):
|
|
await client.sources.wait_until_ready("nb_123", "src_missing")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_timeout_raises(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test wait_until_ready() raises SourceTimeoutError when timeout=0 (line 221-222)."""
|
|
from notebooklm.types import SourceTimeoutError
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceTimeoutError):
|
|
await client.sources.wait_until_ready("nb_123", "src_timeout", timeout=0.0)
|
|
|
|
|
|
class TestAddFileValidation:
|
|
"""Tests for add_file() validation errors (line 429)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_file_directory_raises_validation_error(
|
|
self,
|
|
auth_tokens,
|
|
tmp_path,
|
|
):
|
|
"""Test add_file() raises ValidationError when path is a directory (line 429)."""
|
|
from notebooklm.exceptions import ValidationError
|
|
|
|
# tmp_path is a directory, not a file
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ValidationError, match="Not a regular file"):
|
|
await client.sources.add_file("nb_123", tmp_path)
|
|
|
|
|
|
class TestGetGuideEdgeCases:
|
|
"""Tests for get_guide() inner parsing branches (lines 655->667, 657->667, 659->667)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_guide_outer_not_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_guide() returns empty when outer result[0] is not a list (line 657->667)."""
|
|
# result[0] is a string, not a list
|
|
response = build_rpc_response(RPCMethod.GET_SOURCE_GUIDE, ["not_a_list"])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
guide = await client.sources.get_guide("nb_123", "src_001")
|
|
|
|
assert guide.summary == ""
|
|
assert guide.keywords == ()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_guide_inner_not_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_guide() returns empty when inner outer[0] is not a list (line 659->667)."""
|
|
# outer is a list but outer[0] is a string, not a list
|
|
response = build_rpc_response(RPCMethod.GET_SOURCE_GUIDE, [["not_a_list"]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
guide = await client.sources.get_guide("nb_123", "src_001")
|
|
|
|
assert guide.summary == ""
|
|
assert guide.keywords == ()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_guide_result_false(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_guide() returns empty when result is falsy (line 655->667)."""
|
|
response = build_rpc_response(RPCMethod.GET_SOURCE_GUIDE, None)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
guide = await client.sources.get_guide("nb_123", "src_001")
|
|
|
|
assert guide.summary == ""
|
|
assert guide.keywords == ()
|
|
|
|
|
|
class TestGetFulltextResult0Branches:
|
|
"""Tests for get_fulltext() result[0] parsing branches (lines 710->725, 714->725, 727->732)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_result_0_not_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() when result[0] is not a list - title stays empty (line 710->725)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
# result[0] is a string, not a list
|
|
["just_a_string"],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_no_list")
|
|
|
|
assert fulltext.title == ""
|
|
assert fulltext._type_code is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_result_0_2_not_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() when result[0][2] is not a list (line 714->725)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
# result[0][2] is a string, not a list
|
|
["src_x", "My Title", "not_a_list"],
|
|
None,
|
|
None,
|
|
[[["Some text content."]]],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_x")
|
|
|
|
assert fulltext.title == "My Title"
|
|
assert fulltext._type_code is None
|
|
assert fulltext.url is None
|
|
assert "Some text content" in fulltext.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fulltext_content_blocks_not_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test get_fulltext() when content_blocks (result[3][0]) is not a list (line 727->732)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_SOURCE,
|
|
[
|
|
["src_y", "Source Y", [None, 0, None, None, 4]],
|
|
None,
|
|
None,
|
|
# result[3][0] is a string, not a list
|
|
["just_a_string"],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
fulltext = await client.sources.get_fulltext("nb_123", "src_y")
|
|
|
|
assert fulltext.content == ""
|
|
|
|
|
|
class TestExtractVideoIdFromParsedUrl:
|
|
"""Tests for _extract_video_id_from_parsed_url() branches (lines 835, 843, 846->852)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_youtu_be_with_path(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() extracts ID from youtu.be short URL (line 835)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id("https://youtu.be/dQw4w9WgXcQ")
|
|
|
|
assert result == "dQw4w9WgXcQ"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_shorts_url(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() extracts ID from YouTube Shorts URL (line 843)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id(
|
|
"https://youtube.com/shorts/dQw4w9WgXcQ"
|
|
)
|
|
|
|
assert result == "dQw4w9WgXcQ"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_embed_url(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() extracts ID from embed URL (line 843)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id(
|
|
"https://youtube.com/embed/dQw4w9WgXcQ"
|
|
)
|
|
|
|
assert result == "dQw4w9WgXcQ"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_watch_with_valid_id(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() via watch URL hits query param branch (lines 846->850)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = client.sources._extract_youtube_video_id(
|
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s"
|
|
)
|
|
|
|
assert result == "dQw4w9WgXcQ"
|
|
|
|
|
|
class TestAddYoutubeSourceDirect:
|
|
"""Tests for _add_youtube_source() implementation (lines 870-876)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_url_youtube_video_success(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test add_url() with YouTube URL calls _add_youtube_source internally (lines 870-876)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE,
|
|
[
|
|
[
|
|
[
|
|
["yt_src_001"],
|
|
"Rick Astley - Never Gonna Give You Up",
|
|
[
|
|
None,
|
|
11,
|
|
None,
|
|
None,
|
|
9,
|
|
None,
|
|
1,
|
|
["https://youtube.com/watch?v=dQw4w9WgXcQ"],
|
|
],
|
|
[None, 2],
|
|
]
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.add_url(
|
|
"nb_123", "https://youtube.com/watch?v=dQw4w9WgXcQ"
|
|
)
|
|
|
|
assert source.id == "yt_src_001"
|
|
assert source.kind == "youtube"
|
|
|
|
|
|
class TestRegisterFileSourceError:
|
|
"""Tests for _register_file_source() error paths (lines 925, 931)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_file_source_none_result_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test _register_file_source() raises when result has no extractable ID (line 931)."""
|
|
test_file = tmp_path / "test.pdf"
|
|
test_file.write_bytes(b"%PDF-1.4 fake")
|
|
|
|
# Baseline GET_NOTEBOOK for the register_file_source probe wrapper.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
# Return a response where extract_id returns None - nested empty list
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
# Result is an empty list - extract_id([]) returns None
|
|
[],
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*batchexecute.*"),
|
|
content=rpc_response.encode(),
|
|
)
|
|
# register_file_source now runs the probe BEFORE raising on a
|
|
# source-less response (CodeRabbit fix), so a second baseline-shape
|
|
# response is needed for the probe lookup.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceAddError, match="Failed to get SOURCE_ID"):
|
|
await client.sources.add_file("nb_123", test_file)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_file_source_nested_empty_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test _register_file_source() when extract_id hits base case None (line 925)."""
|
|
test_file = tmp_path / "empty_nested.pdf"
|
|
test_file.write_bytes(b"%PDF-1.4 content")
|
|
|
|
# Baseline GET_NOTEBOOK for the register_file_source probe wrapper.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
# Result is [[[]]] - extract_id([[]]) -> extract_id([]) -> returns None
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
[[[]]],
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*batchexecute.*"),
|
|
content=rpc_response.encode(),
|
|
)
|
|
# register_file_source now runs the probe BEFORE raising on a
|
|
# source-less response (CodeRabbit fix), so a second baseline-shape
|
|
# response is needed for the probe lookup.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceAddError):
|
|
await client.sources.add_file("nb_123", test_file)
|
|
|
|
|
|
class TestStartResumableUploadError:
|
|
"""Tests for _start_resumable_upload() missing upload URL (line 971)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_resumable_upload_missing_url_header(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test _start_resumable_upload() raises when response has no upload URL header (line 971)."""
|
|
test_file = tmp_path / "no_url.pdf"
|
|
test_file.write_bytes(b"%PDF-1.4 content")
|
|
|
|
# Step 0: Baseline GET_NOTEBOOK for the register_file_source probe wrapper.
|
|
_add_register_file_source_baseline_mock(httpx_mock, build_rpc_response)
|
|
# Step 1: Successful RPC registration
|
|
rpc_response = build_rpc_response(
|
|
RPCMethod.ADD_SOURCE_FILE,
|
|
[[[["file_src_001"], "no_url.pdf", [None, None, None, None, 0]]]],
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*batchexecute.*"),
|
|
content=rpc_response.encode(),
|
|
)
|
|
|
|
# Step 2: Upload session response WITHOUT the required upload URL header
|
|
httpx_mock.add_response(
|
|
url=re.compile(r".*upload/_/\?authuser=0$"),
|
|
headers={}, # No x-goog-upload-url header
|
|
content=b"",
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceAddError, match="Failed to get upload URL"):
|
|
await client.sources.add_file("nb_123", test_file)
|
|
|
|
|
|
class TestWaitUntilReadyPolling:
|
|
"""Tests for wait_until_ready() polling loop (lines 234-244)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_polls_until_ready(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test wait_until_ready() polls and returns when source transitions to READY (lines 234-244)."""
|
|
# First response: source is PROCESSING (status=1)
|
|
processing_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook",
|
|
[
|
|
[
|
|
["src_polling"],
|
|
"Processing Source",
|
|
[None, 0],
|
|
[None, 1], # status=PROCESSING
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
# Second response: source is READY (status=2)
|
|
ready_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook",
|
|
[
|
|
[
|
|
["src_polling"],
|
|
"Processing Source",
|
|
[None, 0],
|
|
[None, 2], # status=READY
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=processing_response.encode())
|
|
httpx_mock.add_response(content=ready_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
source = await client.sources.wait_until_ready(
|
|
"nb_123", "src_polling", initial_interval=0.01, timeout=30.0
|
|
)
|
|
|
|
assert source.id == "src_polling"
|
|
|
|
|
|
class TestExtractVideoIdNoQuery:
|
|
"""Tests for _extract_video_id_from_parsed_url() no-query branch (line 846->852)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_youtube_video_id_no_query_string(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test _extract_youtube_video_id() returns None for youtube.com URL with no query at all (line 846->852)."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# youtube.com URL with no path prefix and no query string
|
|
result = client.sources._extract_youtube_video_id("https://youtube.com/")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestWaitUntilReadyErrorPaths:
|
|
"""Tests for wait_until_ready() error state and mid-loop timeout (lines 234-244)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_source_error_raises_processing_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test wait_until_ready() raises SourceProcessingError when source is in ERROR state (line 235)."""
|
|
from notebooklm.types import SourceProcessingError
|
|
|
|
# Source has ERROR status (status=3). Use a non-audio terminal type
|
|
# (3 = PDF) at metadata[4] so the audio-tolerance gate (#391) does
|
|
# not keep polling. Audio (type 10) and unclassified types can briefly
|
|
# report status=3 transiently; PDF cannot.
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Notebook",
|
|
[
|
|
[
|
|
["src_error"],
|
|
"Failed Source",
|
|
[None, 0, None, None, 3], # metadata[4]=3 (PDF)
|
|
[None, 3], # status=ERROR
|
|
]
|
|
],
|
|
"nb_123",
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(SourceProcessingError):
|
|
await client.sources.wait_until_ready("nb_123", "src_error")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_timeout_mid_loop(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test wait_until_ready() raises SourceTimeoutError when timeout expires mid-loop (line 240)."""
|
|
from notebooklm.types import SourceTimeoutError
|
|
|
|
# A PROCESSING source that never becomes ready
|
|
processing_source = Source(id="src_slow", title="Slow Source", status=1)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources,
|
|
"get_or_none",
|
|
new_callable=AsyncMock,
|
|
return_value=processing_source,
|
|
):
|
|
with pytest.raises(SourceTimeoutError):
|
|
await client.sources.wait_until_ready(
|
|
"nb_123",
|
|
"src_slow",
|
|
timeout=0.05, # Very short timeout so polling loop hits elapsed > timeout
|
|
initial_interval=0.001,
|
|
)
|
|
|
|
|
|
class TestWaitUntilReadyMidLoopTimeout:
|
|
"""Test for the mid-loop timeout in wait_until_ready() (line 240)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_remaining_zero_after_get(
|
|
self,
|
|
auth_tokens,
|
|
):
|
|
"""Test wait_until_ready() raises SourceTimeoutError at remaining<=0 check (line 240).
|
|
|
|
This covers the case where elapsed < timeout at the top of the loop,
|
|
but by the time we check remaining after get(), the timeout has expired.
|
|
"""
|
|
from notebooklm.types import SourceTimeoutError
|
|
|
|
# A PROCESSING source
|
|
processing_source = Source(id="src_race", title="Race Source", status=1)
|
|
timeout_val = 1.0
|
|
# Simulate monotonic times: first call (start), second call (elapsed check in loop),
|
|
# third call (remaining check after get) - make third > timeout
|
|
# start=0.0, loop check: elapsed=0.5 < 1.0 (ok), remaining check: elapsed=1.5 > 1.0
|
|
monotonic_values = [0.0, 0.5, 1.5]
|
|
call_idx = 0
|
|
|
|
def fake_monotonic():
|
|
nonlocal call_idx
|
|
val = monotonic_values[min(call_idx, len(monotonic_values) - 1)]
|
|
call_idx += 1
|
|
return val
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.sources,
|
|
"get_or_none",
|
|
new_callable=AsyncMock,
|
|
return_value=processing_source,
|
|
):
|
|
with patch.object(
|
|
_sources_mod, "monotonic", side_effect=fake_monotonic
|
|
) as mock_monotonic:
|
|
with pytest.raises(SourceTimeoutError):
|
|
await client.sources.wait_until_ready(
|
|
"nb_123", "src_race", timeout=timeout_val
|
|
)
|
|
|
|
mock_monotonic.assert_called()
|