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

780 lines
27 KiB
Python

"""Integration tests for NotebooksAPI.
Moved from ``tests/unit/`` to ``tests/integration/``.
Mock-backed (``pytest_httpx``); ``allow_no_vcr`` opts out of the
integration-tree VCR enforcement hook in ``tests/integration/conftest.py``.
Cassette-backed coverage lives in ``tests/integration/test_vcr_comprehensive.py``.
"""
import json
import pytest
from pytest_httpx import HTTPXMock
from notebooklm import Notebook, NotebookLMClient
from notebooklm.exceptions import UnknownRPCMethodError
from notebooklm.rpc import RPCError, RPCMethod
pytestmark = pytest.mark.allow_no_vcr
class TestListNotebooks:
@pytest.mark.asyncio
async def test_list_notebooks_returns_notebooks(
self,
auth_tokens,
httpx_mock: HTTPXMock,
mock_list_notebooks_response,
):
httpx_mock.add_response(content=mock_list_notebooks_response.encode())
async with NotebookLMClient(auth_tokens) as client:
notebooks = await client.notebooks.list()
assert len(notebooks) == 2
assert all(isinstance(nb, Notebook) for nb in notebooks)
assert notebooks[0].title == "My First Notebook"
assert notebooks[0].id == "nb_001"
assert notebooks[0].sources_count == 2
assert notebooks[1].sources_count == 0
@pytest.mark.asyncio
async def test_list_notebooks_request_format(
self,
auth_tokens,
httpx_mock: HTTPXMock,
mock_list_notebooks_response,
):
httpx_mock.add_response(content=mock_list_notebooks_response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notebooks.list()
request = httpx_mock.get_request()
assert request.method == "POST"
assert RPCMethod.LIST_NOTEBOOKS.value in str(request.url)
assert b"f.req=" in request.content
@pytest.mark.asyncio
async def test_request_includes_cookies(
self,
auth_tokens,
httpx_mock: HTTPXMock,
mock_list_notebooks_response,
):
httpx_mock.add_response(content=mock_list_notebooks_response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notebooks.list()
request = httpx_mock.get_request()
cookie_header = request.headers.get("cookie", "")
assert "SID=test_sid" in cookie_header
assert "HSID=test_hsid" in cookie_header
@pytest.mark.asyncio
async def test_request_includes_csrf(
self,
auth_tokens,
httpx_mock: HTTPXMock,
mock_list_notebooks_response,
):
httpx_mock.add_response(content=mock_list_notebooks_response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notebooks.list()
request = httpx_mock.get_request()
body = request.content.decode()
assert "at=test_csrf_token" in body
class TestCreateNotebook:
@pytest.mark.asyncio
async def test_create_notebook(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
# ``create`` snapshots the notebook list before issuing
# CREATE_NOTEBOOK so the probe-then-retry wrapper can detect a
# server-side commit on a transport failure. Stub the baseline
# list response first; then the create response.
baseline_list = build_rpc_response(RPCMethod.LIST_NOTEBOOKS, [[]])
httpx_mock.add_response(content=baseline_list.encode())
response = build_rpc_response(
RPCMethod.CREATE_NOTEBOOK,
[
"My Notebook",
[],
"new_nb_id",
"📓",
None,
[None, None, None, None, None, [1704067200, 0]],
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notebook = await client.notebooks.create("My Notebook")
assert isinstance(notebook, Notebook)
assert notebook.id == "new_nb_id"
assert notebook.title == "My Notebook"
@pytest.mark.asyncio
async def test_create_notebook_request_contains_title(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
# see ``test_create_notebook`` for baseline-list rationale.
baseline_list = build_rpc_response(RPCMethod.LIST_NOTEBOOKS, [[]])
httpx_mock.add_response(content=baseline_list.encode())
response = build_rpc_response(
RPCMethod.CREATE_NOTEBOOK,
["Test Title", [], "id", "📓", None, [None, None, None, None, None, [1704067200, 0]]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notebooks.create("Test Title")
# The create request is the second one; assert it carries the
# CREATE_NOTEBOOK rpcid AND the title we passed in.
requests = httpx_mock.get_requests()
create_requests = [r for r in requests if RPCMethod.CREATE_NOTEBOOK.value in str(r.url)]
assert len(create_requests) == 1
body = create_requests[0].content.decode()
assert "Test+Title" in body or "Test%20Title" in body, (
f"CREATE_NOTEBOOK request body did not contain url-encoded 'Test Title': {body[:200]}"
)
class TestGetNotebook:
@pytest.mark.asyncio
async def test_get_notebook(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
response = build_rpc_response(
RPCMethod.GET_NOTEBOOK,
[
[
"Test Notebook",
[["source1"], ["source2"]],
"nb_123",
"📘",
None,
[None, None, None, None, None, [1704067200, 0]],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notebook = await client.notebooks.get("nb_123")
assert isinstance(notebook, Notebook)
assert notebook.id == "nb_123"
assert notebook.title == "Test Notebook"
# data[1] holds the source list in the GET_NOTEBOOK shape, same as LIST.
assert notebook.sources_count == 2
@pytest.mark.asyncio
async def test_get_notebook_sources_count_matches_real_payload(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Regression: ``Notebook.sources_count`` is derived from ``data[1]``.
Pinned to the shape captured in ``tests/cassettes/notebooks_get.yaml``
(a real GET_NOTEBOOK response) — two PDF source entries at index 1.
If Google ever moves the source list, this test fails before any
downstream code that depends on ``sources_count`` (notably the
divergence warning in ``_notebooks.py``) silently produces a wrong
count.
"""
response = build_rpc_response(
RPCMethod.GET_NOTEBOOK,
[
[
"TypeScript Fundamentals",
[
[
["fdfc8ac4-3237-4f2a-8a79-3e24297a7040"],
"Programming TypeScript.pdf",
[None, 87289, [1767921640, 565022000], None, 3, None, 1],
[None, 2],
],
[
["ddd31154-74a0-484a-a24c-aff796acae2f"],
"typescript-book.pdf",
[None, 22183, [1767921620, 707149000], None, 3, None, 1],
[None, 2],
],
],
"c3f6285f-1709-44c4-9cd6-e95cf0ea4f5e",
"📘",
None,
[None, None, None, None, None, [1768963937, 237838000]],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notebook = await client.notebooks.get("c3f6285f-1709-44c4-9cd6-e95cf0ea4f5e")
assert notebook.sources_count == 2
@pytest.mark.asyncio
async def test_get_notebook_uses_source_path(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
response = build_rpc_response(
RPCMethod.GET_NOTEBOOK,
[["Name", [], "nb_123", "📘", None, [None, None, None, None, None, [1704067200, 0]]]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notebooks.get("nb_123")
request = httpx_mock.get_request()
assert "source-path=%2Fnotebook%2Fnb_123" in str(request.url)
class TestDeleteNotebook:
@pytest.mark.asyncio
async def test_delete_notebook(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
response = build_rpc_response(RPCMethod.DELETE_NOTEBOOK, [True])
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
result = await client.notebooks.delete("nb_123")
assert result is None
class TestSummary:
@pytest.mark.asyncio
async def test_get_summary(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
response = build_rpc_response(
RPCMethod.SUMMARIZE, [[["Summary of the notebook content..."]]]
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
result = await client.notebooks.get_summary("nb_123")
assert "Summary" in result
class TestRenameNotebook:
@pytest.mark.asyncio
async def test_rename_notebook(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
# First response for rename (returns null)
rename_response = build_rpc_response(RPCMethod.RENAME_NOTEBOOK, None)
httpx_mock.add_response(content=rename_response.encode())
# Second response for get_notebook call after rename
get_response = build_rpc_response(
RPCMethod.GET_NOTEBOOK,
[
[
"New Title",
[],
"nb_123",
"📘",
None,
[None, None, None, None, None, [1704067200, 0]],
]
],
)
httpx_mock.add_response(content=get_response.encode())
async with NotebookLMClient(auth_tokens) as client:
notebook = await client.notebooks.rename("nb_123", "New Title")
assert isinstance(notebook, Notebook)
assert notebook.id == "nb_123"
assert notebook.title == "New Title"
@pytest.mark.asyncio
async def test_rename_notebook_request_format(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
# Rename response (returns null)
rename_response = build_rpc_response(RPCMethod.RENAME_NOTEBOOK, None)
httpx_mock.add_response(content=rename_response.encode())
# Get notebook response after rename
get_response = build_rpc_response(
RPCMethod.GET_NOTEBOOK,
[
[
"Renamed",
[],
"nb_123",
"📘",
None,
[None, None, None, None, None, [1704067200, 0]],
]
],
)
httpx_mock.add_response(content=get_response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notebooks.rename("nb_123", "Renamed")
request = httpx_mock.get_requests()[0]
assert RPCMethod.RENAME_NOTEBOOK.value in str(request.url)
assert "source-path=%2F" in str(request.url)
class TestNotebooksAPIAdditional:
"""Additional integration tests for NotebooksAPI."""
@pytest.mark.asyncio
async def test_share_method_removed(self, auth_tokens):
"""NotebooksAPI.share() was removed in v0.8.0 (#1363).
Callers must use ``client.sharing.set_public`` for the public-sharing
toggle and ``client.notebooks.get_share_url`` for the deep-link URL.
"""
async with NotebookLMClient(auth_tokens) as client:
assert not hasattr(client.notebooks, "share")
with pytest.raises(AttributeError):
await client.notebooks.share("nb_123", public=True) # type: ignore[attr-defined]
@pytest.mark.asyncio
async def test_get_summary_additional(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test getting notebook summary."""
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[[["This is a comprehensive summary of the notebook content..."]]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
result = await client.notebooks.get_summary("nb_123")
assert "summary" in result.lower()
@pytest.mark.asyncio
async def test_remove_from_recent(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test removing notebook from recent list."""
response = build_rpc_response("fejl7e", None) # REMOVE_RECENTLY_VIEWED
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
await client.notebooks.remove_from_recent("nb_123")
request = httpx_mock.get_request()
assert "fejl7e" in str(request.url)
@pytest.mark.asyncio
async def test_get_raw(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test getting raw notebook data."""
raw_data = [
["Test Notebook", [["src1"], ["src2"]], "nb_123", "📘"],
["extra", "metadata"],
]
response = build_rpc_response(RPCMethod.GET_NOTEBOOK, raw_data)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
result = await client.notebooks.get_raw("nb_123")
assert result == raw_data
request = httpx_mock.get_request()
assert "source-path=%2Fnotebook%2Fnb_123" in str(request.url)
@pytest.mark.asyncio
async def test_get_description(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test getting notebook description with summary and topics."""
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[
[
["This notebook covers AI research."],
[
[
["What are the main findings?", "Explain the key findings"],
["How was the study conducted?", "Describe methodology"],
]
],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
description = await client.notebooks.get_description("nb_123")
assert description.summary == "This notebook covers AI research."
assert len(description.suggested_topics) == 2
assert description.suggested_topics[0].question == "What are the main findings?"
assert description.suggested_topics[0].prompt == "Explain the key findings"
class TestGetNotebookFailures:
"""Integration tests reproducing Issue #114 GET_NOTEBOOK failures.
Exercises the full NotebookLMClient → rpc_call → decode_response pipeline
with injected HTTP responses matching each failure scenario from Issue #114.
"""
@pytest.mark.asyncio
async def test_empty_response_body(
self,
auth_tokens,
httpx_mock: HTTPXMock,
):
"""Scenario A: Server returns empty body after anti-XSSI prefix."""
raw = b")]}'\n"
httpx_mock.add_response(content=raw)
async with NotebookLMClient(auth_tokens) as client:
with pytest.raises(RPCError, match="response contained no RPC data"):
await client.notebooks.get("nb_123")
@pytest.mark.asyncio
async def test_non_rpc_json_response(
self,
auth_tokens,
httpx_mock: HTTPXMock,
):
"""Scenario B: Server returns JSON chunks but no RPC data."""
chunk = json.dumps({"error": "something"})
raw = f")]}}'\n{len(chunk)}\n{chunk}\n".encode()
httpx_mock.add_response(content=raw)
async with NotebookLMClient(auth_tokens) as client:
with pytest.raises(RPCError, match="response contained no RPC data"):
await client.notebooks.get("nb_123")
@pytest.mark.asyncio
async def test_null_result_data(
self,
auth_tokens,
httpx_mock: HTTPXMock,
):
"""Scenario C: wrb.fr matches GET_NOTEBOOK ID but result_data is None."""
rpc_id = RPCMethod.GET_NOTEBOOK.value
chunk = json.dumps(["wrb.fr", rpc_id, None, None, None, None])
raw = f")]}}'\n{len(chunk)}\n{chunk}\n".encode()
httpx_mock.add_response(content=raw)
async with NotebookLMClient(auth_tokens) as client:
with pytest.raises(RPCError, match="returned null result data"):
await client.notebooks.get("nb_123")
@pytest.mark.asyncio
async def test_short_wrb_fr_item(
self,
auth_tokens,
httpx_mock: HTTPXMock,
):
"""Scenario D: wrb.fr item has only 2 elements (skipped by extract)."""
rpc_id = RPCMethod.GET_NOTEBOOK.value
chunk = json.dumps(["wrb.fr", rpc_id])
raw = f")]}}'\n{len(chunk)}\n{chunk}\n".encode()
httpx_mock.add_response(content=raw)
async with NotebookLMClient(auth_tokens) as client:
with pytest.raises(RPCError, match="returned null result data"):
await client.notebooks.get("nb_123")
class TestNotebookEdgeCases:
"""Test edge cases for NotebooksAPI."""
@pytest.mark.asyncio
async def test_list_notebooks_empty(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test listing notebooks when none exist."""
response = build_rpc_response(RPCMethod.LIST_NOTEBOOKS, [])
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notebooks = await client.notebooks.list()
assert notebooks == []
@pytest.mark.asyncio
async def test_list_notebooks_nested_empty(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test listing notebooks with nested empty array."""
response = build_rpc_response(RPCMethod.LIST_NOTEBOOKS, [[]])
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
notebooks = await client.notebooks.list()
assert notebooks == []
@pytest.mark.asyncio
async def test_get_summary_empty_response_returns_empty(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""A summary-less SUMMARIZE response returns "" rather than drifting.
Regression for #1485: a brand-new, source-less notebook has no summary
yet, so the server returns an empty/absent result[0] payload. That
routine "no summary yet" state must surface as "" instead of being
mis-classified as wire-schema drift. Strict-mode unit coverage lives in
``tests/unit/test_get_summary_drift.py``.
"""
# ``[]`` (no outer[0] slot), ``[None]`` (result[0] is None), and ``[[]]``
# (result[0] is an empty list — no summary slot) are all legitimate
# summary-less payloads. ``[[None]]`` (summary slot explicitly null) is
# the same routine absence, now consistent between get_summary and
# get_description after delegating to _extract_summary (#1485).
for data in ([], [None], [[]], [[None]]):
response = build_rpc_response(RPCMethod.SUMMARIZE, data)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
assert await client.notebooks.get_summary("nb_123") == ""
@pytest.mark.asyncio
async def test_get_summary_malformed_response_raises(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""A present-but-malformed SUMMARIZE payload still drifts and raises.
Distinct from the routinely-absent summary slot (#1485): when result[0]
is present (non-None) but cannot be descended to the summary value, that
is genuine schema drift and must surface as ``UnknownRPCMethodError``
under strict decoding (the only mode; the ``NOTEBOOKLM_STRICT_DECODE=0``
soft-mode opt-out was retired in v0.7.0). This guards against
over-suppressing real drift while fixing the empty-summary case — in
particular a scalar ``result[0]`` must raise, not collapse to "".
"""
# Each payload has a present, non-None result[0] that cannot be descended
# to result[0][0][0]: a non-empty-but-malformed inner list, and a bare
# scalar (the #1485 codex over-suppression case). Contrast with [] /
# [None] / [[]] which are routine absence and return "" above.
for data in ([[[]]], [[42]], [123]):
response = build_rpc_response(RPCMethod.SUMMARIZE, data)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
with pytest.raises(UnknownRPCMethodError):
await client.notebooks.get_summary("nb_123")
@pytest.mark.asyncio
async def test_get_description_empty_topics(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test getting description with no suggested topics."""
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[[["Summary text"], []]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
description = await client.notebooks.get_description("nb_123")
assert description.summary == "Summary text"
assert description.suggested_topics == []
@pytest.mark.asyncio
async def test_get_description_malformed_topics(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Test getting description with malformed topic data."""
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[
[
["Summary"],
[
[
["Valid question", "Valid prompt"],
["Only question"], # Missing prompt
"not a list", # Not a list
]
],
]
],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
description = await client.notebooks.get_description("nb_123")
assert description.summary == "Summary"
# Should only include valid topics
assert len(description.suggested_topics) == 1
assert description.suggested_topics[0].question == "Valid question"
class TestDescribeEdgeCases:
"""Tests for get_description() branch edge cases."""
@pytest.mark.asyncio
async def test_get_description_no_topics_key(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""result has only outer[0] (no outer[1]) so topics stay empty."""
# result = [[["A summary"]]] — outer[0] has summary, no outer[1] for topics
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[[["A summary"]]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
description = await client.notebooks.get_description("nb_123")
assert description.summary == "A summary"
assert description.suggested_topics == []
@pytest.mark.asyncio
async def test_get_description_result_1_is_empty_list(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""outer[1] exists but is an empty list, so topics block is skipped."""
# result = [[["A summary"], []]] — outer[1] is empty, so topics are skipped
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[[["A summary"], []]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
description = await client.notebooks.get_description("nb_123")
assert description.summary == "A summary"
assert description.suggested_topics == []
@pytest.mark.asyncio
async def test_get_description_result_1_not_list(
self,
auth_tokens,
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""outer[1] is present but not a list, so topics block is skipped."""
# result = [[["A summary"], "not-a-list"]] — outer[1] is not a list, topics skipped
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[[["A summary"], "not-a-list"]],
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
description = await client.notebooks.get_description("nb_123")
assert description.summary == "A summary"
assert description.suggested_topics == []
class TestShareEdgeCases:
"""Tests for get_share_url() branch edge cases.
``share()`` was removed in v0.8.0 (#1363); its deep-link URL building and the
public=False url-is-None behavior are covered by ``ShareManager`` unit tests
and the ``get_share_url`` cases below. Use ``client.sharing.set_public`` for
the public-sharing toggle.
"""
@pytest.mark.asyncio
async def test_get_share_url_without_artifact(
self,
auth_tokens,
httpx_mock: HTTPXMock,
):
"""Line 288: get_share_url() without artifact_id returns base URL."""
async with NotebookLMClient(auth_tokens) as client:
url = client.notebooks.get_share_url("nb_123")
assert url == "https://notebooklm.google.com/notebook/nb_123"
@pytest.mark.asyncio
async def test_get_share_url_with_artifact(
self,
auth_tokens,
httpx_mock: HTTPXMock,
):
"""Lines 285-287: get_share_url() with artifact_id appends query param."""
async with NotebookLMClient(auth_tokens) as client:
url = client.notebooks.get_share_url("nb_123", artifact_id="art_789")
assert url == "https://notebooklm.google.com/notebook/nb_123?artifactId=art_789"