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
3201 lines
114 KiB
Python
3201 lines
114 KiB
Python
"""Integration tests for ArtifactsAPI.
|
|
|
|
Moved from ``tests/unit/`` to ``tests/integration/``.
|
|
These tests are mock-backed (``pytest_httpx``) rather than VCR-cassette-
|
|
backed; the ``allow_no_vcr`` module-level marker opts them out of the
|
|
integration-tree VCR enforcement hook in ``tests/integration/conftest.py``.
|
|
Cassette-backed coverage for the same API surface lives in
|
|
``tests/integration/test_vcr_comprehensive.py`` and adjacent
|
|
``test_*_vcr.py`` files.
|
|
"""
|
|
|
|
import csv
|
|
import json
|
|
import re
|
|
import warnings
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from notebooklm import NotebookLMClient
|
|
from notebooklm._artifacts import ArtifactsAPI
|
|
from notebooklm._mind_map import NoteBackedMindMapService
|
|
from notebooklm._note_service import NoteService
|
|
from notebooklm.exceptions import (
|
|
ArtifactNotFoundError,
|
|
RateLimitError,
|
|
UnknownRPCMethodError,
|
|
ValidationError,
|
|
)
|
|
from notebooklm.rpc import (
|
|
AudioFormat,
|
|
AudioLength,
|
|
InfographicStyle,
|
|
RPCError,
|
|
RPCMethod,
|
|
VideoFormat,
|
|
VideoStyle,
|
|
)
|
|
from notebooklm.types import (
|
|
ArtifactDownloadError,
|
|
ArtifactFeatureUnavailableError,
|
|
ArtifactNotReadyError,
|
|
ArtifactParseError,
|
|
ArtifactType,
|
|
MindMapResult,
|
|
UnknownTypeWarning,
|
|
)
|
|
|
|
pytestmark = pytest.mark.allow_no_vcr
|
|
|
|
|
|
class TestStudioContent:
|
|
@pytest.mark.asyncio
|
|
async def test_generate_audio(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
|
|
audio_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["artifact_123", "Audio Overview", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=audio_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_audio(notebook_id="nb_123")
|
|
|
|
assert result is not None
|
|
assert result.task_id == "artifact_123"
|
|
assert result.status in ("pending", "in_progress")
|
|
|
|
request = httpx_mock.get_requests()[-1]
|
|
assert RPCMethod.CREATE_ARTIFACT.value in str(request.url)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_audio_with_format_and_length(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
|
|
response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["artifact_123", "Audio Overview", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_audio(
|
|
notebook_id="nb_123",
|
|
audio_format=AudioFormat.DEBATE,
|
|
audio_length=AudioLength.LONG,
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.task_id == "artifact_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_video_with_format_and_style(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
video_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["artifact_456", "Video Overview", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=video_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_video(
|
|
notebook_id="nb_123",
|
|
video_format=VideoFormat.BRIEF,
|
|
video_style=VideoStyle.ANIME,
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.task_id == "artifact_456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_cinematic_video(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
cinematic_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT,
|
|
[["artifact_789", "Cinematic Video Overview", "2026-03-09", None, 1]],
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=cinematic_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_cinematic_video(
|
|
notebook_id="nb_123",
|
|
instructions="documentary about quantum physics",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.task_id == "artifact_789"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_slide_deck(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
slide_deck_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["artifact_456", "Slide Deck", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=slide_deck_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_slide_deck(notebook_id="nb_123")
|
|
|
|
assert result is not None
|
|
assert result.task_id == "artifact_456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revise_slide(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test revise_slide calls REVISE_SLIDE RPC with correct params."""
|
|
revise_response = build_rpc_response(
|
|
RPCMethod.REVISE_SLIDE,
|
|
[["artifact_456", "Slide Deck", "2024-01-05", None, 1]],
|
|
)
|
|
httpx_mock.add_response(content=revise_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.revise_slide(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
slide_index=0,
|
|
prompt="Move the title up a bit",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.task_id == "artifact_456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_studio_status(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
# LIST_ARTIFACTS format: [[artifact1, artifact2, ...]]
|
|
# Use REPORT type (no URL check needed) for simpler test
|
|
artifact = [
|
|
"task_id_123",
|
|
"Briefing Doc",
|
|
2, # REPORT type (no URL check)
|
|
None,
|
|
3, # COMPLETED status
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status(
|
|
notebook_id="nb_123",
|
|
task_id="task_id_123",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.status == "completed"
|
|
|
|
|
|
class TestGenerateQuiz:
|
|
@pytest.mark.asyncio
|
|
async def test_generate_quiz(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
quiz_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["quiz_123", "Quiz", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=quiz_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_quiz("nb_123")
|
|
|
|
assert result is not None
|
|
assert result.task_id == "quiz_123"
|
|
|
|
|
|
class TestDeleteStudioContent:
|
|
@pytest.mark.asyncio
|
|
async def test_delete_studio_content(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
response = build_rpc_response(RPCMethod.DELETE_ARTIFACT, [True])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.delete("nb_123", "task_id_123")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestMindMap:
|
|
@pytest.mark.asyncio
|
|
async def test_generate_mind_map(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
mindmap_response = build_rpc_response(RPCMethod.GENERATE_MIND_MAP, None)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=mindmap_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_mind_map("nb_123")
|
|
|
|
# Mind map returns a typed MindMapResult with mind_map + note_id.
|
|
assert isinstance(result, MindMapResult)
|
|
assert hasattr(result, "mind_map")
|
|
assert hasattr(result, "note_id")
|
|
|
|
|
|
class TestArtifactsAPI:
|
|
"""Integration tests for ArtifactsAPI methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_artifacts(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing all artifacts."""
|
|
# Response for LIST_ARTIFACTS (gArtLc)
|
|
response1 = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_001", "Audio Overview", 1, None, "completed"],
|
|
["art_002", "Quiz", 4, None, "completed"],
|
|
["art_003", "Study Guide", 2, None, "completed"],
|
|
],
|
|
)
|
|
# Response for GET_NOTES_AND_MIND_MAPS (cFji9) - empty (no mind maps)
|
|
response2 = build_rpc_response(RPCMethod.GET_NOTES_AND_MIND_MAPS, [[]])
|
|
httpx_mock.add_response(content=response1.encode())
|
|
httpx_mock.add_response(content=response2.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list("nb_123")
|
|
|
|
assert isinstance(artifacts, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_raw_preserves_rpc_call_shape(self):
|
|
"""_list_raw keeps the exact LIST_ARTIFACTS RPC contract."""
|
|
from tests._fixtures.fake_core import make_fake_core
|
|
|
|
core = make_fake_core(rpc_call=AsyncMock(return_value=[[]]))
|
|
api = ArtifactsAPI(
|
|
rpc=core,
|
|
drain=core,
|
|
lifecycle=core,
|
|
notebooks=MagicMock(),
|
|
mind_maps=MagicMock(spec=NoteBackedMindMapService),
|
|
note_service=MagicMock(spec=NoteService),
|
|
)
|
|
|
|
result = await api._list_raw("nb_123")
|
|
|
|
assert result == []
|
|
# After ``arch-d2-cutover``, ArtifactsAPI calls
|
|
# ``runtime.rpc_call`` directly (no adapter pass-through), so
|
|
# default kwargs aren't explicitly forwarded — they apply at the
|
|
# underlying :meth:`RpcExecutor.rpc_call` signature instead.
|
|
core.rpc_executor.rpc_call.assert_awaited_once_with(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[[2], "nb_123", 'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"'],
|
|
source_path="/notebook/nb_123",
|
|
allow_null=True,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_raw_preserves_already_flat_artifact_rows(self):
|
|
"""_list_raw must not collapse already-flat artifact rows."""
|
|
from tests._fixtures.fake_core import make_fake_core
|
|
|
|
artifact_rows = [
|
|
["art_001", "My Report", 2, None, 3],
|
|
["art_002", "Audio Overview", 1, None, 3],
|
|
]
|
|
core = make_fake_core(rpc_call=AsyncMock(return_value=artifact_rows))
|
|
api = ArtifactsAPI(
|
|
rpc=core,
|
|
drain=core,
|
|
lifecycle=core,
|
|
notebooks=MagicMock(),
|
|
mind_maps=MagicMock(spec=NoteBackedMindMapService),
|
|
note_service=MagicMock(spec=NoteService),
|
|
)
|
|
|
|
result = await api._list_raw("nb_123")
|
|
|
|
assert result == artifact_rows
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_uses_rpc_listing_and_mind_map_service(self):
|
|
"""list() combines studio artifacts from RPC with the injected mind-map service."""
|
|
from tests._fixtures.fake_core import make_fake_core
|
|
|
|
studio_artifact = ["art_001", "My Report", 2, None, 3]
|
|
mind_map = [
|
|
"mind_map_001",
|
|
["mind_map_001", '{"name":"Map"}', [1, "user", [1704067200, 0]], None, "Map"],
|
|
]
|
|
core = make_fake_core(rpc_call=AsyncMock(return_value=[[studio_artifact]]))
|
|
api = ArtifactsAPI(
|
|
rpc=core,
|
|
drain=core,
|
|
lifecycle=core,
|
|
notebooks=MagicMock(),
|
|
mind_maps=MagicMock(spec=NoteBackedMindMapService),
|
|
note_service=MagicMock(spec=NoteService),
|
|
)
|
|
|
|
with patch.object(
|
|
api._mind_maps,
|
|
"list_mind_maps",
|
|
new=AsyncMock(return_value=[mind_map]),
|
|
) as list_mind_maps:
|
|
artifacts = await api.list("nb_123")
|
|
|
|
core.rpc_call.assert_awaited_once()
|
|
list_mind_maps.assert_awaited_once_with("nb_123")
|
|
assert [artifact.id for artifact in artifacts] == ["art_001", "mind_map_001"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_skips_mind_map_callback_for_non_mind_map_filter(self):
|
|
"""Filtering to studio-only kinds must not fetch mind maps."""
|
|
from tests._fixtures.fake_core import make_fake_core
|
|
|
|
core = make_fake_core(
|
|
rpc_call=AsyncMock(return_value=[[["art_001", "My Report", 2, None, 3]]])
|
|
)
|
|
api = ArtifactsAPI(
|
|
rpc=core,
|
|
drain=core,
|
|
lifecycle=core,
|
|
notebooks=MagicMock(),
|
|
mind_maps=MagicMock(spec=NoteBackedMindMapService),
|
|
note_service=MagicMock(spec=NoteService),
|
|
)
|
|
|
|
with patch.object(
|
|
api._mind_maps,
|
|
"list_mind_maps",
|
|
new=AsyncMock(),
|
|
) as list_mind_maps:
|
|
artifacts = await api.list("nb_123", ArtifactType.REPORT)
|
|
|
|
assert [artifact.id for artifact in artifacts] == ["art_001"]
|
|
list_mind_maps.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_uses_public_list_callback(self):
|
|
"""get() delegates through the public list callback."""
|
|
core = MagicMock()
|
|
api = ArtifactsAPI(
|
|
rpc=core,
|
|
drain=core,
|
|
lifecycle=core,
|
|
notebooks=MagicMock(),
|
|
mind_maps=MagicMock(spec=NoteBackedMindMapService),
|
|
note_service=MagicMock(spec=NoteService),
|
|
)
|
|
other = MagicMock()
|
|
other.id = "art_other"
|
|
found = MagicMock()
|
|
found.id = "art_found"
|
|
|
|
with patch.object(
|
|
api, "list", new=AsyncMock(return_value=[other, found])
|
|
) as list_artifacts:
|
|
result = await api.get("nb_123", "art_found")
|
|
# v0.8.0: a miss now raises ArtifactNotFoundError (issue #1247).
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
await api.get("nb_123", "art_missing")
|
|
|
|
assert result is found
|
|
assert list_artifacts.await_count == 2
|
|
list_artifacts.assert_awaited_with("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_artifact(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Renaming re-fetches (studio-only) and returns the server-reflected Artifact."""
|
|
# 1) RENAME_ARTIFACT (silent on success).
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.RENAME_ARTIFACT.value}.*"),
|
|
content=build_rpc_response(RPCMethod.RENAME_ARTIFACT, None).encode(),
|
|
)
|
|
# 2) LIST_ARTIFACTS hydrate — studio-only (no GET_NOTES_AND_MIND_MAPS),
|
|
# the server reflects the new title.
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.LIST_ARTIFACTS.value}.*"),
|
|
content=build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[["art_001", "New Title", 7, None, 3]],
|
|
).encode(),
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifact = await client.artifacts.rename("nb_123", "art_001", "New Title")
|
|
|
|
assert artifact is not None
|
|
assert artifact.id == "art_001"
|
|
# Server-reflected, not echoed from the input argument.
|
|
assert artifact.title == "New Title"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_artifact_missing_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Renaming an absent artifact raises ArtifactNotFoundError (list-based)."""
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.RENAME_ARTIFACT.value}.*"),
|
|
content=build_rpc_response(RPCMethod.RENAME_ARTIFACT, None).encode(),
|
|
)
|
|
# Studio-only hydrate list does not contain the renamed id.
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.LIST_ARTIFACTS.value}.*"),
|
|
content=build_rpc_response(RPCMethod.LIST_ARTIFACTS, []).encode(),
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
await client.artifacts.rename("nb_123", "missing_art", "New Title")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_artifact_note_backed_mind_map_id_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A note-backed mind-map id renames via UPDATE_NOTE, not RENAME_ARTIFACT.
|
|
|
|
``artifacts.rename`` hydrates from studio artifacts only, so a
|
|
note-backed mind-map id (present only in the notes listing) reads as
|
|
absent and raises — steering the caller to ``mind_maps.rename``.
|
|
"""
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.RENAME_ARTIFACT.value}.*"),
|
|
content=build_rpc_response(RPCMethod.RENAME_ARTIFACT, None).encode(),
|
|
)
|
|
# Studio listing is empty (the id lives only in the notes backing).
|
|
httpx_mock.add_response(
|
|
url=re.compile(rf".*{RPCMethod.LIST_ARTIFACTS.value}.*"),
|
|
content=build_rpc_response(RPCMethod.LIST_ARTIFACTS, []).encode(),
|
|
)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
await client.artifacts.rename("nb_123", "note_backed_mm", "New Title")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_artifact_transport_error_not_rewrapped(self, auth_tokens):
|
|
"""A transport failure during hydration propagates, not ArtifactNotFoundError."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
rpc = AsyncMock(side_effect=[None, RPCError("boom during hydrate")])
|
|
client._rpc_executor.rpc_call = rpc
|
|
with pytest.raises(RPCError):
|
|
await client.artifacts.rename("nb_123", "art_001", "New Title")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_artifact_return_object_false_raises_on_miss(self, auth_tokens):
|
|
"""v0.8.0 (#1362): return_object=False runs the existence preflight too.
|
|
|
|
A null RENAME echo plus an empty LIST_ARTIFACTS hydrate means the target
|
|
is missing, so the False path raises ArtifactNotFoundError instead of
|
|
silently returning None.
|
|
"""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
rpc = AsyncMock(return_value=None)
|
|
client._rpc_executor.rpc_call = rpc
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
await client.artifacts.rename("nb_123", "art_001", "New Title", return_object=False)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_artifact(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test exporting an artifact."""
|
|
response = build_rpc_response(RPCMethod.EXPORT_ARTIFACT, ["export_content_here"])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.export("nb_123", "art_001")
|
|
|
|
assert result is not None
|
|
request = httpx_mock.get_request()
|
|
assert RPCMethod.EXPORT_ARTIFACT.value in str(request.url)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_flashcards(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test generating flashcards."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
flashcards_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["fc_123", "Flashcards", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=flashcards_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_flashcards("nb_123")
|
|
|
|
assert result is not None
|
|
assert result.task_id == "fc_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_study_guide(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test generating study guide."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
guide_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["sg_123", "Study Guide", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=guide_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_study_guide("nb_123")
|
|
|
|
assert result is not None
|
|
assert result.task_id == "sg_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_infographic(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test generating infographic."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
infographic_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["ig_123", "Infographic", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=infographic_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_infographic("nb_123")
|
|
|
|
assert result is not None
|
|
assert result.task_id == "ig_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_infographic_with_style(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test generating infographic with a specific visual style."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
infographic_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["ig_456", "Infographic", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=infographic_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_infographic(
|
|
"nb_123",
|
|
style=InfographicStyle.PROFESSIONAL,
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.task_id == "ig_456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_infographic_null_result_raises_feature_unavailable(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A null CREATE_ARTIFACT result is an unavailable artifact feature."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
null_response = build_rpc_response(RPCMethod.CREATE_ARTIFACT, None)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=null_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactFeatureUnavailableError) as exc_info:
|
|
await client.artifacts.generate_infographic("nb_123")
|
|
|
|
err = exc_info.value
|
|
assert err.artifact_type == "infographic"
|
|
assert err.method_id == RPCMethod.CREATE_ARTIFACT.value
|
|
assert str(err) == "Infographic generation is unavailable"
|
|
|
|
@pytest.mark.parametrize(
|
|
("method_name", "artifact_type"),
|
|
[
|
|
("generate_audio", "audio"),
|
|
("generate_video", "video"),
|
|
("generate_cinematic_video", "cinematic video"),
|
|
("generate_report", "report"),
|
|
("generate_study_guide", "report"),
|
|
("generate_quiz", "quiz"),
|
|
("generate_flashcards", "flashcards"),
|
|
("generate_infographic", "infographic"),
|
|
("generate_slide_deck", "slide deck"),
|
|
("generate_data_table", "data table"),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_public_create_artifact_null_result_raises_feature_unavailable(
|
|
self,
|
|
method_name,
|
|
artifact_type,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Every public CREATE_ARTIFACT generator classifies null before parsing."""
|
|
null_response = build_rpc_response(RPCMethod.CREATE_ARTIFACT, None)
|
|
httpx_mock.add_response(content=null_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
method = getattr(client.artifacts, method_name)
|
|
with pytest.raises(ArtifactFeatureUnavailableError) as exc_info:
|
|
await method("nb_123", source_ids=["src_001"])
|
|
|
|
err = exc_info.value
|
|
assert err.artifact_type == artifact_type
|
|
assert err.method_id == RPCMethod.CREATE_ARTIFACT.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_data_table(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test generating data table."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["source_123"], "Source", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
"📘",
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
table_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["dt_123", "Data Table", "2024-01-05", None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=table_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_data_table("nb_123")
|
|
|
|
assert result is not None
|
|
assert result.task_id == "dt_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_artifact_raises_when_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test getting a non-existent artifact raises ArtifactNotFoundError."""
|
|
# Response for LIST_ARTIFACTS (gArtLc) - empty
|
|
response1 = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [])
|
|
# Response for GET_NOTES_AND_MIND_MAPS (cFji9) - empty
|
|
response2 = build_rpc_response(RPCMethod.GET_NOTES_AND_MIND_MAPS, [[]])
|
|
httpx_mock.add_response(content=response1.encode())
|
|
httpx_mock.add_response(content=response2.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# v0.8.0: a miss now raises ArtifactNotFoundError (issue #1247).
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
await client.artifacts.get("nb_123", "nonexistent")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_audio_artifacts(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing audio artifacts."""
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_001", "Audio Overview", 1, None, 3],
|
|
["art_002", "Quiz", 4, None, 3],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error", UnknownTypeWarning)
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list_audio("nb_123")
|
|
|
|
assert [artifact.id for artifact in artifacts] == ["art_001"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_unknown_artifacts_suppresses_unknown_type_warnings(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Filtering for UNKNOWN should not emit warnings while deciding matches."""
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_audio", "Audio Overview", 1, None, 3],
|
|
["art_quiz_missing_variant", "Quiz", 4, None, 3],
|
|
["art_future_type", "Future Artifact", 99, None, 3],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error", UnknownTypeWarning)
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list("nb_123", ArtifactType.UNKNOWN)
|
|
|
|
assert [artifact.id for artifact in artifacts] == [
|
|
"art_quiz_missing_variant",
|
|
"art_future_type",
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_video_artifacts(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing video artifacts."""
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_001", "Video Overview", 3, None, 3],
|
|
["art_002", "Audio Overview", 1, None, 3],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list_video("nb_123")
|
|
|
|
assert isinstance(artifacts, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_quiz_artifacts(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing quiz artifacts (list_quizzes)."""
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_001", "Quiz", 4, None, 3, None, [None, None, None, None, None, None, 2]],
|
|
[
|
|
"art_002",
|
|
"Flashcards",
|
|
4,
|
|
None,
|
|
3,
|
|
None,
|
|
[None, None, None, None, None, None, 1],
|
|
],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list_quizzes("nb_123")
|
|
|
|
assert isinstance(artifacts, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_artifact(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test deleting an artifact."""
|
|
response = build_rpc_response(RPCMethod.DELETE_ARTIFACT, None)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.delete("nb_123", "art_001")
|
|
|
|
assert result is None
|
|
request = httpx_mock.get_request()
|
|
assert RPCMethod.DELETE_ARTIFACT in str(request.url)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_flashcards(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing flashcard artifacts."""
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_001", "Quiz", 4, None, 3, None, [None, None, None, None, None, None, 2]],
|
|
[
|
|
"art_002",
|
|
"Flashcards",
|
|
4,
|
|
None,
|
|
3,
|
|
None,
|
|
[None, None, None, None, None, None, 1],
|
|
],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list_flashcards("nb_123")
|
|
|
|
assert isinstance(artifacts, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_infographics(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing infographic artifacts."""
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_001", "Infographic", 7, None, 3],
|
|
["art_002", "Audio", 1, None, 3],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list_infographics("nb_123")
|
|
|
|
assert isinstance(artifacts, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_slide_decks(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing slide deck artifacts."""
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
["art_001", "Slide Deck", 8, None, 3],
|
|
["art_002", "Video", 3, None, 3],
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list_slide_decks("nb_123")
|
|
|
|
assert isinstance(artifacts, list)
|
|
|
|
|
|
class TestArtifactErrorPaths:
|
|
"""Test error handling paths in ArtifactsAPI."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_audio_no_completed_audio(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test download_audio raises error when no completed audio exists."""
|
|
# LIST_ARTIFACTS returns empty (no audio artifacts)
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_audio("nb_123", "/tmp/audio.mp4")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_audio_artifact_id_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test download_audio raises error when specific artifact_id not found."""
|
|
# Return an audio artifact but not the one requested
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
[
|
|
["other_audio_id", "Audio", 1, None, 3, None, []],
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_audio(
|
|
"nb_123", "/tmp/audio.mp4", artifact_id="nonexistent_id"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_video_no_completed_video(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test download_video raises error when no completed video exists."""
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_video("nb_123", "/tmp/video.mp4")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_infographic_no_completed(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test download_infographic raises error when none completed."""
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_infographic("nb_123", "/tmp/infographic.png")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_slide_deck_no_completed(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test download_slide_deck raises error when none completed."""
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_slide_deck("nb_123", "/tmp/slides")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_slide_deck_pptx(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""Test download_slide_deck with format='pptx' downloads PPTX URL."""
|
|
import notebooklm._artifact.downloads as _downloads
|
|
|
|
pdf_url = "https://docs.googleusercontent.com/slides.pdf"
|
|
pptx_url = "https://docs.googleusercontent.com/slides.pptx"
|
|
slide_art = [
|
|
"artifact_456",
|
|
"Slide Deck",
|
|
8,
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
[None, "Title", [], pdf_url, pptx_url], # art[16] with PPTX at [4]
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[slide_art]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
httpx_mock.add_response(content=b"pptx-content")
|
|
|
|
# Form-2 object-form patch against the locally-imported seam alias:
|
|
# the download coordinator resolves cookies through the module-level
|
|
# ``load_httpx_cookies`` name on ``_artifact.downloads`` (the
|
|
# ``_load_httpx_cookies`` wrapper calls it), so stub it on the module
|
|
# object to avoid touching real on-disk cookie storage.
|
|
fake_cookies = MagicMock()
|
|
fake_loader = MagicMock(return_value=fake_cookies)
|
|
monkeypatch.setattr(_downloads, "load_httpx_cookies", fake_loader)
|
|
|
|
output = str(tmp_path / "slides.pptx")
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.download_slide_deck(
|
|
"nb_123", output, output_format="pptx"
|
|
)
|
|
assert result == output
|
|
# Bite-check: the patched seam was actually exercised on the download path.
|
|
fake_loader.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_in_progress(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test poll_status returns in_progress for processing artifacts."""
|
|
# LIST_ARTIFACTS format: [[artifact1, artifact2, ...]]
|
|
artifact = [
|
|
"task_id_123",
|
|
"Report",
|
|
2, # REPORT type
|
|
None,
|
|
1, # PROCESSING status
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status(
|
|
notebook_id="nb_123",
|
|
task_id="task_id_123",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.status == "in_progress"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_failed(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test poll_status returns failed status."""
|
|
# LIST_ARTIFACTS format: [[artifact1, artifact2, ...]]
|
|
artifact = [
|
|
"task_id_123",
|
|
"Report",
|
|
2, # REPORT type
|
|
None,
|
|
4, # FAILED status
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status(
|
|
notebook_id="nb_123",
|
|
task_id="task_id_123",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.status == "failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_error_http_500(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""Test RPC error handling for HTTP 500.
|
|
|
|
Uses ``server_error_max_retries=0`` to exercise the immediate-raise
|
|
path; the retry-policy fix adds bounded exponential-backoff retries for 5xx by
|
|
default, but the error-shape contract on exhaustion is what this test
|
|
pins down.
|
|
"""
|
|
httpx_mock.add_response(status_code=500)
|
|
|
|
async with NotebookLMClient(auth_tokens, server_error_max_retries=0) as client:
|
|
with pytest.raises(RPCError, match="Server error 500"):
|
|
await client.artifacts.list("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_empty_result(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test listing artifacts when notebook has none."""
|
|
# Response for LIST_ARTIFACTS (gArtLc) - empty
|
|
response1 = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
# Response for GET_NOTES_AND_MIND_MAPS (cFji9) - empty
|
|
response2 = build_rpc_response(RPCMethod.GET_NOTES_AND_MIND_MAPS, [[]])
|
|
httpx_mock.add_response(content=response1.encode())
|
|
httpx_mock.add_response(content=response2.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
artifacts = await client.artifacts.list("nb_123")
|
|
|
|
assert artifacts == []
|
|
|
|
|
|
class TestDownloadReport:
|
|
"""Integration tests for download_report method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_report_success(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test successful report download."""
|
|
# Mock _list_raw response - type 2 (report), status 3 (completed)
|
|
# Data needs to be [[artifact1], [artifact2], ...] because _list_raw does result[0]
|
|
response = build_rpc_response(
|
|
RPCMethod.LIST_ARTIFACTS,
|
|
[
|
|
[
|
|
[
|
|
"report_001",
|
|
"Study Guide",
|
|
2, # type (report)
|
|
None,
|
|
3, # status (completed)
|
|
None,
|
|
None,
|
|
["# Test Report\n\nThis is markdown content."], # content at index 7
|
|
]
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
output_path = tmp_path / "report.md"
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.download_report("nb_123", str(output_path))
|
|
|
|
assert result == str(output_path)
|
|
assert output_path.exists()
|
|
content = output_path.read_text()
|
|
assert "# Test Report" in content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_report_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test error when no report exists."""
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_report("nb_123", "/tmp/report.md")
|
|
|
|
|
|
class TestDownloadMindMap:
|
|
"""Integration tests for download_mind_map method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_mind_map_success(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test successful mind map download."""
|
|
# Mock notes API response for mind maps
|
|
response = build_rpc_response(
|
|
RPCMethod.GET_NOTES_AND_MIND_MAPS,
|
|
[
|
|
[
|
|
[
|
|
"mindmap_001",
|
|
[None, '{"name": "Root", "children": []}'],
|
|
None,
|
|
None,
|
|
"Mind Map Title",
|
|
]
|
|
]
|
|
],
|
|
)
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
output_path = tmp_path / "mindmap.json"
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.download_mind_map("nb_123", str(output_path))
|
|
|
|
assert result == str(output_path)
|
|
assert output_path.exists()
|
|
data = json.loads(output_path.read_text())
|
|
assert data["name"] == "Root"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_mind_map_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test error when no mind map exists."""
|
|
response = build_rpc_response(RPCMethod.GET_NOTES_AND_MIND_MAPS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_mind_map("nb_123", "/tmp/mindmap.json")
|
|
|
|
|
|
class TestDownloadDataTable:
|
|
"""Integration tests for download_data_table method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_data_table_success(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""Test successful data table download."""
|
|
# Build complex nested structure for data table
|
|
rows_data = [
|
|
[
|
|
0,
|
|
20,
|
|
[
|
|
[0, 5, [[0, 5, [[0, 5, [["Col1"]]]]]]],
|
|
[5, 10, [[5, 10, [[5, 10, [["Col2"]]]]]]],
|
|
],
|
|
],
|
|
[
|
|
20,
|
|
40,
|
|
[
|
|
[20, 25, [[20, 25, [[20, 25, [["A"]]]]]]],
|
|
[25, 30, [[25, 30, [[25, 30, [["B"]]]]]]],
|
|
],
|
|
],
|
|
]
|
|
data_table_structure = [[[[[0, 100, None, None, [6, 7, rows_data]]]]]]
|
|
|
|
artifact = ["table_001", "Data Table", 9, None, 3]
|
|
artifact.extend([None] * 13) # Pad to index 18
|
|
artifact.append(data_table_structure)
|
|
|
|
# Data needs to be [[artifact1]] because _list_raw does result[0]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
output_path = tmp_path / "data.csv"
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.download_data_table("nb_123", str(output_path))
|
|
|
|
assert result == str(output_path)
|
|
assert output_path.exists()
|
|
with open(output_path, encoding="utf-8-sig") as f:
|
|
rows = list(csv.reader(f))
|
|
assert rows[0] == ["Col1", "Col2"]
|
|
assert rows[1] == ["A", "B"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_data_table_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Test error when no data table exists."""
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_data_table("nb_123", "/tmp/data.csv")
|
|
|
|
|
|
# =============================================================================
|
|
# New tests for uncovered lines
|
|
# =============================================================================
|
|
|
|
|
|
class TestExtractAppData:
|
|
"""Tests for _extract_app_data error path (line 78)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_quiz_html_without_app_data_attribute(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""download_quiz raises ArtifactParseError when HTML lacks data-app-data attribute."""
|
|
# Build a completed quiz artifact - variant at data[9][1][0] = 2 for quiz
|
|
# (variant=1 is flashcards, variant=2 is quiz)
|
|
artifact_data = [
|
|
"quiz_001", # [0] id
|
|
"My Quiz", # [1] title
|
|
4, # [2] QUIZ type
|
|
None, # [3]
|
|
3, # [4] COMPLETED
|
|
None, # [5]
|
|
None, # [6]
|
|
None, # [7]
|
|
None, # [8]
|
|
[None, [2]], # [9] options: [9][1][0] = 2 => quiz variant
|
|
None, # [10]
|
|
None, # [11]
|
|
None, # [12]
|
|
None, # [13]
|
|
None, # [14]
|
|
[[1704067200]], # [15] created_at
|
|
]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
|
|
# HTML that has NO data-app-data attribute
|
|
html_without_data = "<html><body><div>No app data here</div></body></html>"
|
|
html_response = build_rpc_response(
|
|
RPCMethod.GET_INTERACTIVE_HTML,
|
|
[[None, None, None, None, None, None, None, None, None, [html_without_data]]],
|
|
)
|
|
httpx_mock.add_response(content=html_response.encode())
|
|
|
|
output_path = str(tmp_path / "quiz.json")
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactParseError, match="data-app-data"):
|
|
await client.artifacts.download_quiz("nb_123", output_path)
|
|
|
|
|
|
class TestListMindMapErrorHandling:
|
|
"""Tests for list() mind map error handling (lines 288-295)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_continues_when_mind_map_rpc_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""list() returns studio artifacts when mind map fetch raises RPCError."""
|
|
# Return a report artifact from studio
|
|
artifact_data = ["art_001", "My Report", 2, None, 3]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# After issue #691, ArtifactsAPI reaches mind-map rows through the
|
|
# injected NoteBackedMindMapService. Patch the service's list_mind_maps so
|
|
# the simulated error reaches ArtifactsAPI.list().
|
|
with patch.object(
|
|
client.artifacts._mind_maps,
|
|
"list_mind_maps",
|
|
new=AsyncMock(side_effect=RPCError("mind map fetch failed")),
|
|
):
|
|
result = await client.artifacts.list("nb_123")
|
|
|
|
# Should still return the studio artifact despite mind map failure
|
|
assert isinstance(result, list)
|
|
assert len(result) >= 1
|
|
assert any(a.id == "art_001" for a in result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_continues_when_mind_map_http_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""list() returns studio artifacts when mind map fetch raises HTTPError."""
|
|
import httpx
|
|
|
|
artifact_data = ["art_002", "Audio Overview", 1, None, 3]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.artifacts._mind_maps,
|
|
"list_mind_maps",
|
|
new=AsyncMock(side_effect=httpx.HTTPError("connection failed")),
|
|
):
|
|
result = await client.artifacts.list("nb_123")
|
|
|
|
assert isinstance(result, list)
|
|
assert len(result) >= 1
|
|
assert any(a.id == "art_002" for a in result)
|
|
|
|
|
|
class TestGetArtifactRaisesWhenNotFound:
|
|
"""Tests for get() raising ArtifactNotFoundError on a miss (lines 312-313)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_raises_when_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""get() raises ArtifactNotFoundError when the artifact_id is not in the list."""
|
|
# Return one artifact with a different ID
|
|
artifact_data = ["art_exists", "My Report", 2, None, 3]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
notes_response = build_rpc_response(RPCMethod.GET_NOTES_AND_MIND_MAPS, [[]])
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
httpx_mock.add_response(content=notes_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# v0.8.0: a miss now raises ArtifactNotFoundError (issue #1247).
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
await client.artifacts.get("nb_123", "art_does_not_exist")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_returns_artifact_when_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""get() returns the artifact when found by ID."""
|
|
artifact_data = ["art_found", "My Report", 2, None, 3]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
notes_response = build_rpc_response(RPCMethod.GET_NOTES_AND_MIND_MAPS, [[]])
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
httpx_mock.add_response(content=notes_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.get("nb_123", "art_found")
|
|
|
|
assert result is not None
|
|
assert result.id == "art_found"
|
|
|
|
|
|
class TestReviseSlide:
|
|
"""Tests for revise_slide() paths (lines 836, 851, 853-861)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revise_slide_negative_index_raises_validation_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""revise_slide raises ValidationError for slide_index < 0."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ValidationError, match="slide_index must be >= 0"):
|
|
await client.artifacts.revise_slide(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
slide_index=-1,
|
|
prompt="Move title up",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revise_slide_null_result_raises_feature_unavailable(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""revise_slide classifies a null RPC result before parser drift handling."""
|
|
null_response = build_rpc_response(RPCMethod.REVISE_SLIDE, None)
|
|
httpx_mock.add_response(content=null_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactFeatureUnavailableError) as exc_info:
|
|
await client.artifacts.revise_slide(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
slide_index=0,
|
|
prompt="Remove taxonomy section",
|
|
)
|
|
|
|
err = exc_info.value
|
|
assert err.artifact_type == "slide revision"
|
|
assert err.method_id == RPCMethod.REVISE_SLIDE.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revise_slide_user_displayable_error_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""v0.8.0 (#1342): revise_slide re-raises on a USER_DISPLAYABLE_ERROR refusal."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
err = RPCError("Rate limit exceeded")
|
|
err.rpc_code = "USER_DISPLAYABLE_ERROR"
|
|
# ``ArtifactsAPI`` now stores its three runtime collaborators
|
|
# directly; the patch goes through ``_rpc`` (the
|
|
# ``RpcExecutor``) since that is what ``rpc_call`` resolves
|
|
# through.
|
|
with (
|
|
patch.object(
|
|
client.artifacts._rpc,
|
|
"rpc_call",
|
|
AsyncMock(side_effect=err),
|
|
),
|
|
pytest.raises(RPCError, match="Rate limit exceeded"),
|
|
):
|
|
await client.artifacts.revise_slide(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
slide_index=2,
|
|
prompt="Make it simpler",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revise_slide_other_rpc_error_reraises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""revise_slide re-raises non-USER_DISPLAYABLE_ERROR RPCErrors."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
err = RPCError("Internal error")
|
|
err.rpc_code = "INTERNAL_ERROR"
|
|
# See sibling test above for the ``_rpc`` patch-target rationale.
|
|
with (
|
|
patch.object(
|
|
client.artifacts._rpc,
|
|
"rpc_call",
|
|
AsyncMock(side_effect=err),
|
|
),
|
|
pytest.raises(RPCError),
|
|
):
|
|
await client.artifacts.revise_slide(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
slide_index=0,
|
|
prompt="Fix this",
|
|
)
|
|
|
|
|
|
class TestRetryFailedArtifact:
|
|
"""Tests for ArtifactsAPI.retry_failed (RETRY_ARTIFACT, issue #1319)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_failed_accepted_returns_in_progress(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""An accepted retry returns the same artifact id as in_progress."""
|
|
# Captured wire shape: the artifact row at index 0 carries the same id
|
|
# (row[0]) and status code 1 (row[4] → PROCESSING → in_progress).
|
|
retry_response = build_rpc_response(
|
|
RPCMethod.RETRY_ARTIFACT,
|
|
[["artifact_456", "Video Overview", 3, [[["src_001"]]], 1]],
|
|
)
|
|
httpx_mock.add_response(content=retry_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.retry_failed(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
)
|
|
|
|
assert result.task_id == "artifact_456"
|
|
assert result.status == "in_progress"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_failed_null_result_raises_feature_unavailable(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A null RPC result raises ArtifactFeatureUnavailableError."""
|
|
null_response = build_rpc_response(RPCMethod.RETRY_ARTIFACT, None)
|
|
httpx_mock.add_response(content=null_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactFeatureUnavailableError) as exc_info:
|
|
await client.artifacts.retry_failed(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
)
|
|
|
|
err = exc_info.value
|
|
assert err.artifact_type == "retry"
|
|
assert err.method_id == RPCMethod.RETRY_ARTIFACT.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_failed_missing_id_row_raises_feature_unavailable(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A non-null row with no artifact id raises rather than soft-failing.
|
|
|
|
Born ADR-0019-correct: a missing/empty id means no generation task was
|
|
created, so retry_failed raises ArtifactFeatureUnavailableError instead
|
|
of returning the ``status="failed"`` that ``_parse_generation_result``
|
|
synthesizes for a falsy id.
|
|
"""
|
|
# Row present but row[0] (the id) is null — not a fully-null result.
|
|
degenerate = build_rpc_response(RPCMethod.RETRY_ARTIFACT, [[None, "Title", 3, [], 1]])
|
|
httpx_mock.add_response(content=degenerate.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactFeatureUnavailableError) as exc_info:
|
|
await client.artifacts.retry_failed(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
)
|
|
|
|
assert exc_info.value.method_id == RPCMethod.RETRY_ARTIFACT.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_failed_user_displayable_error_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A synchronous USER_DISPLAYABLE_ERROR refusal RAISES (ADR-0019).
|
|
|
|
Unlike generate_* / revise_slide, retry_failed does NOT swallow the
|
|
refusal into status="failed" — it lets the RateLimitError propagate.
|
|
"""
|
|
err = RateLimitError("Rate limit exceeded", rpc_code="USER_DISPLAYABLE_ERROR")
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with (
|
|
patch.object(
|
|
client.artifacts._rpc,
|
|
"rpc_call",
|
|
AsyncMock(side_effect=err),
|
|
),
|
|
pytest.raises(RateLimitError) as exc_info,
|
|
):
|
|
await client.artifacts.retry_failed(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
)
|
|
|
|
assert exc_info.value.rpc_code == "USER_DISPLAYABLE_ERROR"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_failed_other_rpc_error_propagates(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""A non-rate-limit RPCError refusal also propagates unchanged."""
|
|
err = RPCError("Internal error", rpc_code="INTERNAL_ERROR")
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with (
|
|
patch.object(
|
|
client.artifacts._rpc,
|
|
"rpc_call",
|
|
AsyncMock(side_effect=err),
|
|
),
|
|
pytest.raises(RPCError) as exc_info,
|
|
):
|
|
await client.artifacts.retry_failed(
|
|
notebook_id="nb_123",
|
|
artifact_id="artifact_456",
|
|
)
|
|
|
|
# Not silently downgraded into a feature-unavailable error.
|
|
assert not isinstance(exc_info.value, ArtifactFeatureUnavailableError)
|
|
|
|
|
|
class TestGenerateMindMapParsing:
|
|
"""Tests for generate_mind_map() result parsing (lines 957-986)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_mind_map_returns_none_on_empty_result(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""generate_mind_map returns {'mind_map': None, 'note_id': None} when RPC returns None."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
mindmap_response = build_rpc_response(RPCMethod.GENERATE_MIND_MAP, None)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=mindmap_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_mind_map("nb_123")
|
|
|
|
assert result.mind_map is None
|
|
assert result.note_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_mind_map_parses_json_string(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""generate_mind_map parses JSON string in RPC result and creates a note."""
|
|
mind_map_dict = {"name": "Root Topic", "children": [{"name": "Child 1"}]}
|
|
mind_map_json_str = json.dumps(mind_map_dict)
|
|
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
# RPC response: [[json_string]]
|
|
mindmap_response = build_rpc_response(RPCMethod.GENERATE_MIND_MAP, [[mind_map_json_str]])
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=mindmap_response.encode())
|
|
|
|
from notebooklm.types import Note
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
# After Phase 5, mind-map persistence flows through
|
|
# ``ArtifactsAPI._note_service.create_note`` rather than the
|
|
# legacy ``_mind_map.create_note`` module-level seam.
|
|
with patch.object(
|
|
client.artifacts._note_service,
|
|
"create_note",
|
|
new=AsyncMock(
|
|
return_value=Note(
|
|
id="note_created_001",
|
|
notebook_id="nb_123",
|
|
title="Root Topic",
|
|
content=mind_map_json_str,
|
|
)
|
|
),
|
|
):
|
|
result = await client.artifacts.generate_mind_map("nb_123")
|
|
|
|
assert result.mind_map == mind_map_dict
|
|
assert result.note_id == "note_created_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_mind_map_handles_dict_not_string(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""generate_mind_map handles case where mind_map_json is already a dict."""
|
|
mind_map_dict = {"name": "Topic", "children": []}
|
|
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
# RPC response: [[dict_object]] — not a string, already parsed
|
|
mindmap_response = build_rpc_response(RPCMethod.GENERATE_MIND_MAP, [[mind_map_dict]])
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=mindmap_response.encode())
|
|
|
|
from notebooklm.types import Note
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with patch.object(
|
|
client.artifacts._note_service,
|
|
"create_note",
|
|
new=AsyncMock(
|
|
return_value=Note(
|
|
id="note_dict_001",
|
|
notebook_id="nb_123",
|
|
title="Topic",
|
|
content=json.dumps(mind_map_dict),
|
|
)
|
|
),
|
|
):
|
|
result = await client.artifacts.generate_mind_map("nb_123")
|
|
|
|
assert result.mind_map == mind_map_dict
|
|
assert result.note_id == "note_dict_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_mind_map_with_source_ids_none_fetches_sources(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""generate_mind_map with source_ids=None fetches source IDs from GET_NOTEBOOK."""
|
|
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
mindmap_response = build_rpc_response(RPCMethod.GENERATE_MIND_MAP, None)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=mindmap_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_mind_map("nb_123", source_ids=None)
|
|
|
|
assert result.mind_map is None
|
|
assert result.note_id is None
|
|
|
|
|
|
class TestDownloadAudioErrorPaths:
|
|
"""Tests for download_audio error paths (lines 1098-1147, 1177-1179)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_audio_empty_list_raises_not_ready(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_audio raises ArtifactNotReadyError when no audio artifacts found."""
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_audio("nb_123", "/tmp/audio.mp4")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_audio_artifact_id_not_found_raises_not_ready(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_audio raises ArtifactNotReadyError when specified artifact_id not found."""
|
|
# Completed audio artifact but with a different ID
|
|
audio_art = [
|
|
"other_audio_001",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
[None, None, None, None, None, [[]]], # metadata at [6]
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[audio_art]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_audio(
|
|
"nb_123", "/tmp/audio.mp4", artifact_id="requested_but_missing"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_audio_invalid_metadata_structure_raises_parse_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_audio raises ArtifactParseError when metadata[6] has wrong structure."""
|
|
# Audio artifact with art[6] being a short list (len <= 5)
|
|
audio_art = [
|
|
"audio_001",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
[None, None], # art[6] — too short, len <= 5
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[audio_art]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactParseError, match="Could not extract download URL"):
|
|
await client.artifacts.download_audio("nb_123", "/tmp/audio.mp4")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_audio_no_media_urls_raises_parse_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_audio raises ArtifactParseError when media_list is empty."""
|
|
# Audio artifact with art[6][5] being an empty list
|
|
audio_art = [
|
|
"audio_001",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
[None, None, None, None, None, []], # art[6], art[6][5] = []
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[audio_art]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactParseError, match="Could not extract download URL"):
|
|
await client.artifacts.download_audio("nb_123", "/tmp/audio.mp4")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_audio_index_error_raises_parse_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_audio wraps IndexError/TypeError in ArtifactParseError."""
|
|
# art[6] is a string, not a list — will cause TypeError
|
|
audio_art = [
|
|
"audio_001",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
"not_a_list", # art[6] is wrong type, causes TypeError
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[audio_art]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactParseError, match="Failed to parse audio artifact"):
|
|
await client.artifacts.download_audio("nb_123", "/tmp/audio.mp4")
|
|
|
|
|
|
class TestGenerateMindMapSourceIdsNone:
|
|
"""Tests for generate methods with source_ids=None (lines 1189-1211)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_audio_source_ids_none_fetches_sources(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""generate_audio with source_ids=None calls GET_NOTEBOOK to fetch source IDs."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_abc"], "Source A", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
audio_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["audio_new", "Audio Overview", None, None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=audio_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_audio("nb_123", source_ids=None)
|
|
|
|
assert result.task_id == "audio_new"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_data_table_source_ids_none_fetches_sources(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""generate_data_table with source_ids=None calls GET_NOTEBOOK to fetch source IDs."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_xyz"], "Source X", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
table_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["dt_new", "Data Table", None, None, 1]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=table_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_data_table("nb_123", source_ids=None)
|
|
|
|
assert result.task_id == "dt_new"
|
|
|
|
|
|
class TestPollStatusVariousPaths:
|
|
"""Tests for poll_status() various status paths (lines 1412-1518)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_not_found_artifact_not_in_list(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'not_found' when task_id not found in artifact list."""
|
|
# Return artifacts that don't include the task_id we're polling
|
|
artifact = ["some_other_artifact", "Report", 2, None, 3]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "unknown_task_id")
|
|
|
|
assert result.status == "not_found"
|
|
assert result.task_id == "unknown_task_id"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_completed_audio_without_url_downgrades_to_processing(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status downgrades audio from COMPLETED to in_progress when URLs missing."""
|
|
# Audio artifact with COMPLETED status but no media URLs (art[6] missing)
|
|
artifact = [
|
|
"audio_task",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED status
|
|
# No art[6] — missing media URL data
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "audio_task")
|
|
|
|
# Should be downgraded to in_progress since URL is not ready
|
|
assert result.status == "in_progress"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_completed_report_no_url_check(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'completed' for non-media types without URL check."""
|
|
# Report artifact (non-media) — status=COMPLETED should pass through directly
|
|
artifact = [
|
|
"report_task",
|
|
"Briefing Doc",
|
|
2, # REPORT type — non-media
|
|
None,
|
|
3, # COMPLETED
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "report_task")
|
|
|
|
assert result.status == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_completed_audio_with_valid_url(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'completed' for audio when URLs are ready."""
|
|
# Audio artifact with proper media URL structure
|
|
audio_artifact = [
|
|
"audio_ready",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
[
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
[["https://storage.googleapis.com/audio.mp4", None, "audio/mp4"]],
|
|
],
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[audio_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "audio_ready")
|
|
|
|
assert result.status == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_empty_list_returns_not_found(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'not_found' when artifact list is empty."""
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "some_task_id")
|
|
|
|
assert result.status == "not_found"
|
|
|
|
|
|
class TestCallGenerateErrorHandling:
|
|
"""Tests for _call_generate() error handling (USER_DISPLAYABLE_ERROR path)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_audio_user_displayable_error_raises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""v0.8.0 (#1342): _call_generate re-raises on a USER_DISPLAYABLE_ERROR refusal."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
err = RPCError("You have exceeded your quota")
|
|
err.rpc_code = "USER_DISPLAYABLE_ERROR"
|
|
# Pass source_ids explicitly so get_source_ids (GET_NOTEBOOK) is NOT called.
|
|
# Then patch the held ``rpc`` collaborator's ``rpc_call`` so the
|
|
# CREATE_ARTIFACT call raises the error. ``ArtifactsAPI``
|
|
# stores its three runtime collaborators directly, so the
|
|
# patch target is ``_rpc`` — see the ``revise_slide``
|
|
# siblings above for the same rationale.
|
|
with (
|
|
patch.object(
|
|
client.artifacts._rpc,
|
|
"rpc_call",
|
|
AsyncMock(side_effect=err),
|
|
),
|
|
pytest.raises(RPCError, match="exceeded your quota"),
|
|
):
|
|
await client.artifacts.generate_audio("nb_123", source_ids=["src_001"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_audio_other_rpc_error_reraises(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""_call_generate re-raises non-USER_DISPLAYABLE_ERROR RPCErrors."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
err = RPCError("Server error")
|
|
err.rpc_code = "INTERNAL_ERROR"
|
|
# Pass source_ids explicitly so get_source_ids (GET_NOTEBOOK) is NOT called.
|
|
# Then patch the held ``rpc`` collaborator's ``rpc_call`` so the
|
|
# CREATE_ARTIFACT call raises the error. See sibling tests above
|
|
# for the ``_rpc`` patch-target rationale.
|
|
with (
|
|
patch.object(
|
|
client.artifacts._rpc,
|
|
"rpc_call",
|
|
AsyncMock(side_effect=err),
|
|
),
|
|
pytest.raises(RPCError),
|
|
):
|
|
await client.artifacts.generate_audio("nb_123", source_ids=["src_001"])
|
|
|
|
|
|
class TestDownloadUrlValidation:
|
|
"""Tests for _download_url security validation (lines 2041-2044)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_url_non_https_raises_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""_download_url raises ArtifactDownloadError for non-HTTPS URLs."""
|
|
from notebooklm.types import ArtifactDownloadError
|
|
|
|
# Build completed audio artifact with HTTP (not HTTPS) URL
|
|
audio_artifact = [
|
|
"audio_insecure",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
[
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
[["http://storage.googleapis.com/audio.mp4", None, "audio/mp4"]],
|
|
],
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[audio_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactDownloadError, match="must use HTTPS"):
|
|
await client.artifacts.download_audio("nb_123", "/tmp/audio.mp4")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_url_untrusted_domain_raises_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""_download_url raises ArtifactDownloadError for untrusted domains."""
|
|
from notebooklm.types import ArtifactDownloadError
|
|
|
|
audio_artifact = [
|
|
"audio_untrusted",
|
|
"Audio Overview",
|
|
1, # AUDIO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
[
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
[["https://evil.example.com/audio.mp4", None, "audio/mp4"]],
|
|
],
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[audio_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactDownloadError, match="Untrusted download domain"):
|
|
await client.artifacts.download_audio("nb_123", "/tmp/audio.mp4")
|
|
|
|
|
|
class TestParseGenerationResult:
|
|
"""Tests for _parse_generation_result (lines 2092-2095, 2117-2123)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_raises_on_empty_artifact_result(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""An empty (non-null) CREATE_ARTIFACT result drifts and raises.
|
|
|
|
Strict decoding is the only mode (the ``NOTEBOOKLM_STRICT_DECODE=0``
|
|
soft-mode opt-out was retired in v0.7.0); an empty ``[]`` result is
|
|
not the null-result FeatureUnavailable case, so it reaches the parser
|
|
and surfaces shape drift as ``UnknownRPCMethodError``.
|
|
"""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
# CREATE_ARTIFACT returns an empty list — no artifact_id
|
|
empty_response = build_rpc_response(RPCMethod.CREATE_ARTIFACT, [])
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=empty_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(UnknownRPCMethodError):
|
|
await client.artifacts.generate_audio("nb_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_null_result_raises_feature_unavailable(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""Public generators classify null CREATE_ARTIFACT before parser drift handling."""
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
# CREATE_ARTIFACT returns null result
|
|
null_response = build_rpc_response(RPCMethod.CREATE_ARTIFACT, None)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=null_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactFeatureUnavailableError) as exc_info:
|
|
await client.artifacts.generate_audio("nb_123")
|
|
|
|
err = exc_info.value
|
|
assert err.artifact_type == "audio"
|
|
assert err.method_id == RPCMethod.CREATE_ARTIFACT.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_returns_status_from_artifact_data(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
monkeypatch,
|
|
):
|
|
"""_parse_generation_result reads status_code from artifact_data[4]."""
|
|
# Post-PR 13.9a default is strict; pin soft mode to preserve the
|
|
# legacy GenerationStatus(failed) sentinel this test covers.
|
|
monkeypatch.setenv("NOTEBOOKLM_STRICT_DECODE", "0")
|
|
notebook_response = build_rpc_response(
|
|
RPCMethod.GET_NOTEBOOK,
|
|
[
|
|
[
|
|
"Test Notebook",
|
|
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
|
|
"nb_123",
|
|
None,
|
|
None,
|
|
[None, None, None, None, None, [1704067200, 0]],
|
|
]
|
|
],
|
|
)
|
|
# CREATE_ARTIFACT returns artifact with status_code=3 (COMPLETED)
|
|
completed_response = build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT, [["art_done", "Audio", 1, None, 3]]
|
|
)
|
|
httpx_mock.add_response(content=notebook_response.encode())
|
|
httpx_mock.add_response(content=completed_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.generate_audio("nb_123")
|
|
|
|
assert result.task_id == "art_done"
|
|
assert result.status == "completed"
|
|
|
|
|
|
class TestGetArtifactTypeNameAndIsMediaReady:
|
|
"""Tests for _get_artifact_type_name and _is_media_ready helper methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_quiz_completed_no_url_check(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'completed' for quiz without URL check (non-media type)."""
|
|
artifact = [
|
|
"quiz_task",
|
|
"Quiz",
|
|
4, # QUIZ type
|
|
None,
|
|
3, # COMPLETED
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "quiz_task")
|
|
|
|
assert result.status == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_data_table_completed_no_url_check(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'completed' for data table without URL check."""
|
|
artifact = [
|
|
"table_task",
|
|
"Data Table",
|
|
9, # DATA_TABLE type
|
|
None,
|
|
3, # COMPLETED
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "table_task")
|
|
|
|
assert result.status == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_video_completed_with_valid_url(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'completed' for video when art[8] has valid URL."""
|
|
# Video artifact with art[8] containing a URL.
|
|
# Real-API shape: art[8][i][0][0] holds the URL string (matches the
|
|
# structure parsed by download_video).
|
|
video_artifact = [
|
|
"video_task",
|
|
"Video Overview",
|
|
3, # VIDEO type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
None,
|
|
None,
|
|
[[["https://storage.googleapis.com/video.mp4"]]], # art[8]
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[video_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "video_task")
|
|
|
|
assert result.status == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_video_completed_without_url_downgrades(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status downgrades video from COMPLETED to in_progress when URL missing."""
|
|
video_artifact = [
|
|
"video_task",
|
|
"Video Overview",
|
|
3, # VIDEO type
|
|
None,
|
|
3, # COMPLETED — but no art[8]
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[video_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "video_task")
|
|
|
|
assert result.status == "in_progress"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_infographic_completed_without_url_downgrades(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status downgrades infographic from COMPLETED to in_progress when URL missing."""
|
|
infographic_artifact = [
|
|
"ig_task",
|
|
"Infographic",
|
|
7, # INFOGRAPHIC type
|
|
None,
|
|
3, # COMPLETED — but no nested URL structure
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[infographic_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "ig_task")
|
|
|
|
assert result.status == "in_progress"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_slide_deck_completed_with_valid_url(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status returns 'completed' for slide deck when art[16][3] has valid URL."""
|
|
slide_artifact = [
|
|
"slide_task",
|
|
"Slide Deck",
|
|
8, # SLIDE_DECK type
|
|
None,
|
|
3, # COMPLETED
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
[None, "Title", None, "https://docs.googleusercontent.com/slides.pdf"], # art[16]
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[slide_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "slide_task")
|
|
|
|
assert result.status == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_slide_deck_completed_without_url_downgrades(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""poll_status downgrades slide deck from COMPLETED to in_progress when URL missing."""
|
|
slide_artifact = [
|
|
"slide_task",
|
|
"Slide Deck",
|
|
8, # SLIDE_DECK type
|
|
None,
|
|
3, # COMPLETED — but no art[16]
|
|
]
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[slide_artifact]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.poll_status("nb_123", "slide_task")
|
|
|
|
assert result.status == "in_progress"
|
|
|
|
|
|
class TestDownloadQuizFlashcardParsing:
|
|
"""Tests for download_quiz/flashcard parsing error paths."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_flashcards_html_without_app_data(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_flashcards raises ArtifactParseError when HTML lacks data-app-data."""
|
|
artifact_data = [
|
|
"fc_001", # [0] id
|
|
"My Flashcards", # [1] title
|
|
4, # [2] QUIZ type (also used for flashcards)
|
|
None, # [3]
|
|
3, # [4] COMPLETED
|
|
None, # [5]
|
|
None, # [6]
|
|
None, # [7]
|
|
None, # [8]
|
|
[None, [1]], # [9] options: [9][1][0] = 1 => flashcards variant
|
|
None, # [10]
|
|
None, # [11]
|
|
None, # [12]
|
|
None, # [13]
|
|
None, # [14]
|
|
[[1704067200]], # [15] created_at
|
|
]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
|
|
html_without_data = "<html><body><p>No app data</p></body></html>"
|
|
html_response = build_rpc_response(
|
|
RPCMethod.GET_INTERACTIVE_HTML,
|
|
[[None, None, None, None, None, None, None, None, None, [html_without_data]]],
|
|
)
|
|
httpx_mock.add_response(content=html_response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactParseError, match="data-app-data"):
|
|
await client.artifacts.download_flashcards("nb_123", "/tmp/flashcards.json")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_quiz_invalid_output_format_raises_validation_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_quiz raises ValidationError for invalid output_format."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ValidationError, match="Invalid output_format"):
|
|
await client.artifacts.download_quiz("nb_123", "/tmp/quiz.xyz", output_format="xyz")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_flashcards_invalid_output_format_raises_validation_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_flashcards raises ValidationError for invalid output_format."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ValidationError, match="Invalid output_format"):
|
|
await client.artifacts.download_flashcards(
|
|
"nb_123", "/tmp/flashcards.bad", output_format="csv"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_quiz_no_completed_raises_not_ready(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""download_quiz raises ArtifactNotReadyError when no completed quiz exists."""
|
|
# Return empty list — no quiz artifacts
|
|
response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[]])
|
|
httpx_mock.add_response(content=response.encode())
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactNotReadyError):
|
|
await client.artifacts.download_quiz("nb_123", "/tmp/quiz.json")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_quiz_null_interactive_html_raises_download_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""download_quiz maps null GET_INTERACTIVE_HTML to ArtifactDownloadError."""
|
|
artifact_data = [
|
|
"quiz_null_html", # [0] id
|
|
"Null HTML Quiz", # [1] title
|
|
4, # [2] QUIZ type
|
|
None, # [3]
|
|
3, # [4] COMPLETED
|
|
None, # [5]
|
|
None, # [6]
|
|
None, # [7]
|
|
None, # [8]
|
|
[None, [2]], # [9] options: [9][1][0] = 2 => quiz variant
|
|
None, # [10]
|
|
None, # [11]
|
|
None, # [12]
|
|
None, # [13]
|
|
None, # [14]
|
|
[[1704067200]], # [15] created_at
|
|
]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
null_html_response = build_rpc_response(RPCMethod.GET_INTERACTIVE_HTML, None)
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
httpx_mock.add_response(content=null_html_response.encode())
|
|
|
|
output_path = str(tmp_path / "quiz.json")
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactDownloadError, match="Failed to fetch content"):
|
|
await client.artifacts.download_quiz("nb_123", output_path)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_flashcards_null_interactive_html_raises_download_error(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""download_flashcards maps null GET_INTERACTIVE_HTML to ArtifactDownloadError."""
|
|
artifact_data = [
|
|
"flashcards_null_html", # [0] id
|
|
"Null HTML Flashcards", # [1] title
|
|
4, # [2] QUIZ type
|
|
None, # [3]
|
|
3, # [4] COMPLETED
|
|
None, # [5]
|
|
None, # [6]
|
|
None, # [7]
|
|
None, # [8]
|
|
[None, [1]], # [9] options: [9][1][0] = 1 => flashcards variant
|
|
None, # [10]
|
|
None, # [11]
|
|
None, # [12]
|
|
None, # [13]
|
|
None, # [14]
|
|
[[1704067200]], # [15] created_at
|
|
]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
null_html_response = build_rpc_response(RPCMethod.GET_INTERACTIVE_HTML, None)
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
httpx_mock.add_response(content=null_html_response.encode())
|
|
|
|
output_path = str(tmp_path / "flashcards.json")
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(ArtifactDownloadError, match="Failed to fetch content"):
|
|
await client.artifacts.download_flashcards("nb_123", output_path)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_quiz_html_format_returns_raw_html(
|
|
self,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
tmp_path,
|
|
):
|
|
"""download_quiz with output_format='html' writes raw HTML content."""
|
|
artifact_data = [
|
|
"quiz_html_001", # [0] id
|
|
"HTML Quiz", # [1] title
|
|
4, # [2] QUIZ type
|
|
None, # [3]
|
|
3, # [4] COMPLETED
|
|
None, # [5]
|
|
None, # [6]
|
|
None, # [7]
|
|
None, # [8]
|
|
[None, [2]], # [9] options: [9][1][0] = 2 => quiz variant
|
|
None, # [10]
|
|
None, # [11]
|
|
None, # [12]
|
|
None, # [13]
|
|
None, # [14]
|
|
[[1704067200]], # [15] created_at
|
|
]
|
|
list_response = build_rpc_response(RPCMethod.LIST_ARTIFACTS, [[artifact_data]])
|
|
httpx_mock.add_response(content=list_response.encode())
|
|
|
|
raw_html = '<html><body data-app-data="{"quiz":[]}">content</body></html>'
|
|
html_response = build_rpc_response(
|
|
RPCMethod.GET_INTERACTIVE_HTML,
|
|
[[None, None, None, None, None, None, None, None, None, [raw_html]]],
|
|
)
|
|
httpx_mock.add_response(content=html_response.encode())
|
|
|
|
output_path = str(tmp_path / "quiz.html")
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
result = await client.artifacts.download_quiz(
|
|
"nb_123", output_path, output_format="html"
|
|
)
|
|
|
|
assert result == output_path
|
|
from pathlib import Path
|
|
|
|
assert Path(output_path).read_text() == raw_html
|
|
|
|
|
|
class TestWaitForCompletionPollIntervalRemoved:
|
|
"""The deprecated ``poll_interval`` keyword was removed in v0.7.0."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_completion_rejects_poll_interval(self, auth_tokens):
|
|
"""``wait_for_completion`` no longer accepts the removed ``poll_interval``.
|
|
|
|
Only ``initial_interval`` remains (see ``docs/deprecations.md``); the
|
|
deprecated alias was removed, so Python's argument binding rejects it.
|
|
"""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
with pytest.raises(TypeError):
|
|
await client.artifacts.wait_for_completion("nb_123", "task_dep", poll_interval=1.0)
|
|
|
|
|
|
def _decoded_request_body(request) -> str:
|
|
"""Return the readable URL-decoded body of the latest CREATE_ARTIFACT POST.
|
|
|
|
The batchexecute payload double-encodes the inner params (the f.req
|
|
array is JSON, and one of its slots is itself a JSON-encoded string),
|
|
so we further normalise the embedded backslash-escaped quotes back to
|
|
plain ``"`` to make substring assertions stable.
|
|
"""
|
|
from urllib.parse import unquote_plus
|
|
|
|
raw = unquote_plus(request.content.decode("utf-8"))
|
|
# Collapse the JSON-in-JSON escaping so that '"ja"' literally appears.
|
|
return raw.replace('\\"', '"')
|
|
|
|
|
|
_LANGUAGE_AWARE_GENERATORS = [
|
|
"generate_audio",
|
|
"generate_video",
|
|
"generate_cinematic_video",
|
|
"generate_report",
|
|
"generate_study_guide",
|
|
"generate_infographic",
|
|
"generate_slide_deck",
|
|
"generate_data_table",
|
|
"generate_mind_map",
|
|
]
|
|
|
|
|
|
def _register_generate_responses(method_name: str, httpx_mock, build_rpc_response) -> int:
|
|
"""Register the HTTP responses required for one ``generate_*`` call and
|
|
return the index of the request that carries the language code.
|
|
|
|
Most language-aware generators issue a single CREATE_ARTIFACT call.
|
|
``generate_mind_map`` is the outlier: it calls GENERATE_MIND_MAP first,
|
|
then CREATE_NOTE + UPDATE_NOTE to persist the result. The language code
|
|
is embedded only in the first request, so the assertion target differs.
|
|
"""
|
|
if method_name == "generate_mind_map":
|
|
# GENERATE_MIND_MAP returns mind-map JSON; subsequent note RPCs persist it.
|
|
httpx_mock.add_response(
|
|
content=build_rpc_response(
|
|
RPCMethod.GENERATE_MIND_MAP,
|
|
[['{"name": "Root", "children": []}', None, ["note_xyz"]]],
|
|
).encode()
|
|
)
|
|
httpx_mock.add_response(
|
|
content=build_rpc_response(
|
|
RPCMethod.CREATE_NOTE,
|
|
[["note_xyz"]],
|
|
).encode()
|
|
)
|
|
httpx_mock.add_response(
|
|
content=build_rpc_response(
|
|
RPCMethod.UPDATE_NOTE,
|
|
[["note_xyz"]],
|
|
).encode()
|
|
)
|
|
return 0 # language travels with the first (GENERATE_MIND_MAP) request
|
|
|
|
httpx_mock.add_response(
|
|
content=build_rpc_response(
|
|
RPCMethod.CREATE_ARTIFACT,
|
|
[["artifact_123", "Title", "2024-01-05", None, 1]],
|
|
).encode()
|
|
)
|
|
return -1 # last (and only) request carries the language
|
|
|
|
|
|
class TestGenerateLanguageCompatibility:
|
|
"""Language-aware generate_* methods preserve the public API default.
|
|
|
|
Omitting ``language`` keeps the historical Python API default of English.
|
|
Passing ``language=None`` opts in to the environment/default resolver in the
|
|
private generation service, and a concrete language still wins.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("method_name", _LANGUAGE_AWARE_GENERATORS)
|
|
async def test_generate_omitted_language_defaults_to_english(
|
|
self,
|
|
method_name,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
monkeypatch,
|
|
):
|
|
"""NOTEBOOKLM_HL=ja, no language arg -> outgoing RPC still carries 'en'."""
|
|
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
|
|
|
|
request_index = _register_generate_responses(method_name, httpx_mock, build_rpc_response)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
method = getattr(client.artifacts, method_name)
|
|
await method(notebook_id="nb_123", source_ids=["src_001"])
|
|
|
|
body = _decoded_request_body(httpx_mock.get_requests()[request_index])
|
|
assert '"en"' in body
|
|
assert '"ja"' not in body
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("method_name", _LANGUAGE_AWARE_GENERATORS)
|
|
async def test_generate_explicit_none_uses_env_language(
|
|
self,
|
|
method_name,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
monkeypatch,
|
|
):
|
|
"""NOTEBOOKLM_HL=ja, language=None -> outgoing RPC carries 'ja'."""
|
|
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
|
|
|
|
request_index = _register_generate_responses(method_name, httpx_mock, build_rpc_response)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
method = getattr(client.artifacts, method_name)
|
|
await method(notebook_id="nb_123", source_ids=["src_001"], language=None)
|
|
|
|
body = _decoded_request_body(httpx_mock.get_requests()[request_index])
|
|
assert '"ja"' in body
|
|
assert '"en"' not in body
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("method_name", _LANGUAGE_AWARE_GENERATORS)
|
|
async def test_generate_explicit_language_overrides_env(
|
|
self,
|
|
method_name,
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
monkeypatch,
|
|
):
|
|
"""NOTEBOOKLM_HL=ja but explicit language='ko' -> outgoing carries 'ko'."""
|
|
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
|
|
|
|
request_index = _register_generate_responses(method_name, httpx_mock, build_rpc_response)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
method = getattr(client.artifacts, method_name)
|
|
await method(
|
|
notebook_id="nb_123",
|
|
source_ids=["src_001"],
|
|
language="ko",
|
|
)
|
|
|
|
body = _decoded_request_body(httpx_mock.get_requests()[request_index])
|
|
assert '"ko"' in body
|
|
assert '"ja"' not in body
|