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

1094 lines
43 KiB
Python

"""Comprehensive VCR tests for all NotebookLM API operations.
This file records cassettes for ALL API operations.
Run with NOTEBOOKLM_VCR_RECORD=1 to record new cassettes.
Recording requires the same env vars as e2e tests:
- NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: For read-only operations
- NOTEBOOKLM_GENERATION_NOTEBOOK_ID: For mutable operations
Note: Notebook IDs only matter when RECORDING. During replay, VCR uses
recorded responses regardless of notebook ID.
Note: These tests are automatically skipped if cassettes are not available.
"""
import csv
import json
import os
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
import pytest
from notebooklm import NotebookLMClient, ReportFormat
from notebooklm.types import Artifact, ArtifactType
from tests.integration.conftest import get_vcr_auth, skip_no_cassettes
from tests.vcr_config import notebooklm_vcr
# Skip all tests in this module if cassettes are not available
pytestmark = [pytest.mark.vcr, skip_no_cassettes]
# Use same env vars as e2e tests for consistency.
#
# These only matter during recording for endpoints whose matcher ignores the
# request body (most batchexecute calls). For body-aware matchers
# — notably ``freq`` on the streaming-chat endpoint — replay also
# needs to send the SAME notebook_id that was recorded, because the matcher
# compares slot 7 of the decoded ``f.req`` envelope. We therefore default
# ``MUTABLE_NOTEBOOK_ID`` to the canonical recording notebook UUID
# used to record the chat cassettes; recording-time runs override
# this with the real env var.
READONLY_NOTEBOOK_ID = os.environ.get(
"NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID",
"c3f6285f-1709-44c4-9cd6-e95cf0ea4f5e",
)
MUTABLE_NOTEBOOK_ID = os.environ.get(
"NOTEBOOKLM_GENERATION_NOTEBOOK_ID",
"bb00c9e3-656c-4fd2-b890-2b71e1cf3814",
)
# =============================================================================
# Helper for reducing boilerplate
# =============================================================================
@asynccontextmanager
async def vcr_client():
"""Context manager for creating authenticated VCR client."""
auth = await get_vcr_auth()
async with NotebookLMClient(auth) as client:
yield client
# =============================================================================
# Notebooks API
# =============================================================================
class TestNotebooksAPI:
"""Notebooks API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_list.yaml")
async def test_list(self):
"""List all notebooks."""
async with vcr_client() as client:
notebooks = await client.notebooks.list()
assert isinstance(notebooks, list)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_get.yaml")
async def test_get(self):
"""Get a specific notebook."""
async with vcr_client() as client:
notebook = await client.notebooks.get(READONLY_NOTEBOOK_ID)
assert notebook is not None
if READONLY_NOTEBOOK_ID:
assert notebook.id == READONLY_NOTEBOOK_ID
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_get_summary.yaml")
async def test_get_summary(self):
"""Get notebook summary."""
async with vcr_client() as client:
summary = await client.notebooks.get_summary(READONLY_NOTEBOOK_ID)
assert isinstance(summary, str)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_get_description.yaml")
async def test_get_description(self):
"""Get notebook description."""
async with vcr_client() as client:
description = await client.notebooks.get_description(READONLY_NOTEBOOK_ID)
assert description is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_get_raw.yaml")
async def test_get_raw(self):
"""Get raw notebook data."""
async with vcr_client() as client:
raw = await client.notebooks.get_raw(READONLY_NOTEBOOK_ID)
assert raw is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_rename.yaml")
async def test_rename(self):
"""Rename a notebook (then rename back)."""
async with vcr_client() as client:
notebook = await client.notebooks.get(MUTABLE_NOTEBOOK_ID)
original_name = notebook.title
await client.notebooks.rename(MUTABLE_NOTEBOOK_ID, "VCR Test Renamed")
await client.notebooks.rename(MUTABLE_NOTEBOOK_ID, original_name)
# =============================================================================
# Sources API
# =============================================================================
class TestSourcesAPI:
"""Sources API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_list.yaml")
async def test_list(self):
"""List sources in a notebook."""
async with vcr_client() as client:
sources = await client.sources.list(READONLY_NOTEBOOK_ID)
assert isinstance(sources, list)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_get_guide.yaml")
async def test_get_guide(self):
"""Get source guide for a specific source."""
async with vcr_client() as client:
sources = await client.sources.list(READONLY_NOTEBOOK_ID)
if not sources:
pytest.skip("No sources available")
guide = await client.sources.get_guide(READONLY_NOTEBOOK_ID, sources[0].id)
assert guide is not None
# Verify values are actually populated (catches parsing bugs like issue #70)
assert guide.summary, "Expected non-empty summary from source guide"
assert isinstance(guide.keywords, tuple)
assert len(guide.keywords) > 0, "Expected non-empty keywords from source guide"
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_get_fulltext.yaml")
async def test_get_fulltext(self):
"""Get source fulltext content."""
async with vcr_client() as client:
sources = await client.sources.list(READONLY_NOTEBOOK_ID)
if not sources:
pytest.skip("No sources available")
fulltext = await client.sources.get_fulltext(READONLY_NOTEBOOK_ID, sources[0].id)
assert fulltext is not None
assert fulltext.source_id == sources[0].id
# Verify content is actually populated (catches parsing bugs like issue #70)
assert fulltext.content, "Expected non-empty content from fulltext"
assert fulltext.title, "Expected non-empty title from fulltext"
assert fulltext.char_count > 0, "Expected positive char_count"
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_add_text.yaml")
async def test_add_text(self):
"""Add a text source."""
async with vcr_client() as client:
source = await client.sources.add_text(
MUTABLE_NOTEBOOK_ID,
title="VCR Test Source",
content="This is a test source created by VCR recording.",
)
assert source is not None
assert source.title == "VCR Test Source"
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_add_url.yaml")
async def test_add_url(self):
"""Add a URL source."""
async with vcr_client() as client:
source = await client.sources.add_url(
MUTABLE_NOTEBOOK_ID,
url="https://en.wikipedia.org/wiki/Artificial_intelligence",
)
assert source is not None
assert source.id, "Expected non-empty source ID"
# Title may be extracted from the page
assert source.title is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_add_drive.yaml")
async def test_add_drive(self):
"""Add a Google Drive document source.
Uses a public Google Doc for testing. The add_drive() function
uses single-wrapped params [source_data] (not double-wrapped).
"""
async with vcr_client() as client:
source = await client.sources.add_drive(
MUTABLE_NOTEBOOK_ID,
file_id="1oAk_INJHbIPsIh49jgNqj3FESSGHZrzxFY7t05Lvvl0",
title="VCR Test Drive Doc",
mime_type="application/vnd.google-apps.document",
wait=False, # Don't wait for processing during VCR recording
)
assert source is not None
assert source.id, "Expected non-empty source ID"
# Drive sources use the actual document title, not the passed title
assert source.title, "Expected non-empty source title"
# =============================================================================
# Notes API
# =============================================================================
class TestNotesAPI:
"""Notes API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notes_list.yaml")
async def test_list(self):
"""List notes in a notebook."""
async with vcr_client() as client:
notes = await client.notes.list(READONLY_NOTEBOOK_ID)
assert isinstance(notes, list)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notes_list_mind_maps.yaml")
async def test_list_mind_maps(self):
"""List mind maps in a notebook."""
async with vcr_client() as client:
mind_maps = await client.notes.list_mind_maps(READONLY_NOTEBOOK_ID)
assert isinstance(mind_maps, list)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notes_create.yaml")
async def test_create(self):
"""Create a note."""
async with vcr_client() as client:
note = await client.notes.create(
MUTABLE_NOTEBOOK_ID,
title="VCR Test Note",
content="This is a test note created by VCR recording.",
)
assert note is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notes_create_and_update.yaml")
async def test_create_and_update(self):
"""Create and update a note."""
async with vcr_client() as client:
note = await client.notes.create(
MUTABLE_NOTEBOOK_ID,
title="VCR Update Test",
content="Original content.",
)
assert note is not None
# v0.8.0 (#1362): update() runs an existence preflight; stub the
# just-created note as a hit so the cassette (recorded pre-flip,
# without the extra GET_NOTES_AND_MIND_MAPS round-trip) still
# replays the create+update interactions only.
client.notes.get_or_none = AsyncMock(return_value=note)
await client.notes.update(
MUTABLE_NOTEBOOK_ID,
note.id,
title="VCR Update Test - Updated",
content="Updated content.",
)
# =============================================================================
# Artifacts API - Read Operations
# =============================================================================
# Artifact list method configurations: (method_name, cassette_name)
ARTIFACT_LIST_METHODS = [
("list", "artifacts_list.yaml"),
("list_audio", "artifacts_list_audio.yaml"),
("list_video", "artifacts_list_video.yaml"),
("list_reports", "artifacts_list_reports.yaml"),
("list_quizzes", "artifacts_list_quizzes.yaml"),
("list_flashcards", "artifacts_list_flashcards.yaml"),
("list_infographics", "artifacts_list_infographics.yaml"),
("list_slide_decks", "artifacts_list_slide_decks.yaml"),
("list_data_tables", "artifacts_list_data_tables.yaml"),
]
class TestArtifactsListAPI:
"""Artifacts API list operations - parametrized to reduce duplication."""
@pytest.mark.vcr
@pytest.mark.asyncio
@pytest.mark.parametrize("method_name,cassette", ARTIFACT_LIST_METHODS)
async def test_list_artifacts(self, method_name, cassette):
"""Test artifact list methods."""
with notebooklm_vcr.use_cassette(cassette):
async with vcr_client() as client:
method = getattr(client.artifacts, method_name)
if method_name == "list":
result = await method(READONLY_NOTEBOOK_ID)
else:
result = await method(READONLY_NOTEBOOK_ID)
assert isinstance(result, list)
# Every element the decoder hands back must be a fully-formed
# Artifact with a non-empty id/title and a known status. This
# rejects "the call replays but the parser silently returned
# garbage" — the failure mode the original ``isinstance(list)``
# check would not catch.
for art in result:
assert isinstance(art, Artifact)
assert isinstance(art.id, str) and art.id
assert isinstance(art.title, str)
assert isinstance(art.status, int)
@pytest.mark.vcr
@pytest.mark.asyncio
@pytest.mark.parametrize(
("method_name", "cassette", "expected_kind", "expected_type_code"),
[
(
"list_infographics",
"artifacts_list_infographics.yaml",
ArtifactType.INFOGRAPHIC,
7,
),
(
"list_data_tables",
"artifacts_list_data_tables.yaml",
ArtifactType.DATA_TABLE,
9,
),
],
)
async def test_list_artifacts_kind_parsing(
self, method_name, cassette, expected_kind, expected_type_code
):
"""Parser turns INFOGRAPHIC (7) and DATA_TABLE (9) rows into the right kind.
The two cassettes were already wired
into :data:`ARTIFACT_LIST_METHODS`, but the surrounding assertion only
proved the call replayed — not that the decoder mapped the integer
type code to the user-facing :class:`ArtifactType` enum. This test
asserts the full parser contract: at least one artifact is returned,
every artifact carries the expected ``_artifact_type`` integer, and
every artifact's ``.kind`` property resolves to the expected enum.
"""
with notebooklm_vcr.use_cassette(cassette):
async with vcr_client() as client:
method = getattr(client.artifacts, method_name)
result = await method(READONLY_NOTEBOOK_ID)
assert isinstance(result, list)
assert len(result) >= 1, f"{cassette} should contain at least one artifact"
for art in result:
assert isinstance(art, Artifact)
assert art._artifact_type == expected_type_code, (
f"Expected raw type code {expected_type_code}, "
f"got {art._artifact_type} for artifact {art.id!r}"
)
assert art.kind == expected_kind, (
f"Expected .kind == {expected_kind!r}, got {art.kind!r} for artifact {art.id!r}"
)
# The .kind property is a str-enum so equality holds both ways.
assert art.kind == expected_kind.value
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_suggest_reports.yaml")
async def test_suggest_reports(self):
"""Get report suggestions."""
async with vcr_client() as client:
suggestions = await client.artifacts.suggest_reports(READONLY_NOTEBOOK_ID)
assert isinstance(suggestions, list)
class TestArtifactsDownloadAPI:
"""Artifacts API download operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_download_report.yaml")
async def test_download_report(self, tmp_path):
"""Download a report as markdown."""
async with vcr_client() as client:
output_path = tmp_path / "report.md"
try:
path = await client.artifacts.download_report(
READONLY_NOTEBOOK_ID, str(output_path)
)
assert os.path.exists(path)
content = output_path.read_text(encoding="utf-8")
assert len(content) > 0 and "#" in content
except ValueError as e:
if "No completed report" in str(e):
pytest.skip("No completed report artifact available")
raise
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_download_mind_map.yaml")
async def test_download_mind_map(self, tmp_path):
"""Download a mind map as JSON."""
async with vcr_client() as client:
output_path = tmp_path / "mindmap.json"
try:
path = await client.artifacts.download_mind_map(
READONLY_NOTEBOOK_ID, str(output_path)
)
assert os.path.exists(path)
data = json.loads(output_path.read_text(encoding="utf-8"))
assert "name" in data
except ValueError as e:
if "No mind maps found" in str(e):
pytest.skip("No mind map artifact available")
raise
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_download_data_table.yaml")
async def test_download_data_table(self, tmp_path):
"""Download a data table as CSV."""
async with vcr_client() as client:
output_path = tmp_path / "data.csv"
try:
path = await client.artifacts.download_data_table(
READONLY_NOTEBOOK_ID, str(output_path)
)
assert os.path.exists(path)
with open(output_path, encoding="utf-8-sig") as f:
rows = list(csv.reader(f))
assert len(rows) >= 1
except ValueError as e:
if "No completed data table" in str(e):
pytest.skip("No completed data table artifact available")
raise
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_download_quiz.yaml")
async def test_download_quiz(self, tmp_path):
"""Download a quiz as JSON."""
async with vcr_client() as client:
output_path = tmp_path / "quiz.json"
try:
path = await client.artifacts.download_quiz(READONLY_NOTEBOOK_ID, str(output_path))
assert os.path.exists(path)
data = json.loads(output_path.read_text(encoding="utf-8"))
assert "title" in data
assert "questions" in data
except ValueError as e:
if "No completed quiz" in str(e):
pytest.skip("No completed quiz artifact available")
raise
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_download_quiz_markdown.yaml")
async def test_download_quiz_markdown(self, tmp_path):
"""Download a quiz as markdown."""
async with vcr_client() as client:
output_path = tmp_path / "quiz.md"
try:
path = await client.artifacts.download_quiz(
READONLY_NOTEBOOK_ID, str(output_path), output_format="markdown"
)
assert os.path.exists(path)
content = output_path.read_text(encoding="utf-8")
assert "# " in content # Should have a heading
assert "Question" in content or "##" in content
except ValueError as e:
if "No completed quiz" in str(e):
pytest.skip("No completed quiz artifact available")
raise
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_download_flashcards.yaml")
async def test_download_flashcards(self, tmp_path):
"""Download flashcards as JSON."""
async with vcr_client() as client:
output_path = tmp_path / "flashcards.json"
try:
path = await client.artifacts.download_flashcards(
READONLY_NOTEBOOK_ID, str(output_path)
)
assert os.path.exists(path)
data = json.loads(output_path.read_text(encoding="utf-8"))
assert "title" in data
assert "cards" in data
# Verify normalized format (front/back, not f/b)
if data["cards"]:
assert "front" in data["cards"][0]
assert "back" in data["cards"][0]
except ValueError as e:
if "No completed flashcard" in str(e):
pytest.skip("No completed flashcard artifact available")
raise
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_download_flashcards_markdown.yaml")
async def test_download_flashcards_markdown(self, tmp_path):
"""Download flashcards as markdown."""
async with vcr_client() as client:
output_path = tmp_path / "flashcards.md"
try:
path = await client.artifacts.download_flashcards(
READONLY_NOTEBOOK_ID, str(output_path), output_format="markdown"
)
assert os.path.exists(path)
content = output_path.read_text(encoding="utf-8")
assert "# " in content # Should have a heading
assert "**Q:**" in content or "Card" in content
except ValueError as e:
if "No completed flashcard" in str(e):
pytest.skip("No completed flashcard artifact available")
raise
# =============================================================================
# Artifacts API - Generation Operations (use mutable notebook)
# =============================================================================
class TestArtifactsGenerateAPI:
"""Artifacts API generation operations.
These tests generate artifacts which may take time and consume quota.
They use the mutable notebook to avoid polluting the read-only one.
"""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_generate_report.yaml")
async def test_generate_report(self):
"""Generate a briefing doc report."""
async with vcr_client() as client:
result = await client.artifacts.generate_report(
MUTABLE_NOTEBOOK_ID,
report_format=ReportFormat.BRIEFING_DOC,
)
assert result is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_generate_study_guide.yaml")
async def test_generate_study_guide(self):
"""Generate a study guide."""
async with vcr_client() as client:
result = await client.artifacts.generate_study_guide(MUTABLE_NOTEBOOK_ID)
assert result is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_generate_quiz.yaml")
async def test_generate_quiz(self):
"""Generate a quiz."""
async with vcr_client() as client:
result = await client.artifacts.generate_quiz(MUTABLE_NOTEBOOK_ID)
assert result is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_generate_flashcards.yaml")
async def test_generate_flashcards(self):
"""Generate flashcards."""
async with vcr_client() as client:
result = await client.artifacts.generate_flashcards(MUTABLE_NOTEBOOK_ID)
assert result is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_retry_failed.yaml")
async def test_retry_failed(self):
"""Retry a failed artifact in place — the same id comes back in_progress."""
artifact_id = "11111111-2222-3333-4444-555555555555"
async with vcr_client() as client:
result = await client.artifacts.retry_failed(MUTABLE_NOTEBOOK_ID, artifact_id)
assert result.task_id == artifact_id
assert result.status == "in_progress"
# =============================================================================
# Chat API
# =============================================================================
class TestChatAPI:
"""Chat API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette(
"chat_ask.yaml",
# Opt this streaming-chat test in to the ``freq`` body matcher.
# The matcher decodes the form-encoded ``f.req`` payload so two
# otherwise-identical POSTs (same method/scheme/host/port/path) can be
# disambiguated by their param shape. ``freq`` is opt-in per-cassette
# because most endpoints do not send ``f.req``.
match_on=["method", "scheme", "host", "port", "path", "freq"],
)
async def test_ask(self):
"""Ask a question."""
async with vcr_client() as client:
result = await client.chat.ask(
MUTABLE_NOTEBOOK_ID,
"What is this notebook about?",
)
assert result is not None
assert result.answer is not None
assert result.conversation_id is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette(
"chat_ask_with_references.yaml",
# Opt-in to the ``freq`` body matcher so the streaming-chat
# POST is disambiguated by its decoded ``f.req`` payload rather than
# by replay-order. See ``test_ask`` above for the full rationale.
match_on=["method", "scheme", "host", "port", "path", "freq"],
)
async def test_ask_with_references(self):
"""Ask a question that generates references."""
async with vcr_client() as client:
result = await client.chat.ask(
MUTABLE_NOTEBOOK_ID,
"Summarize the key points with specific citations from the sources.",
)
assert result is not None
assert result.answer is not None
# References may or may not be present depending on the answer
assert isinstance(result.references, list)
# If references exist, verify structure
for ref in result.references:
assert ref.source_id is not None
assert ref.citation_number is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("chat_get_history.yaml")
async def test_get_conversation_id(self):
"""Get conversation ID."""
async with vcr_client() as client:
conv_id = await client.chat.get_conversation_id(MUTABLE_NOTEBOOK_ID)
# May be None if no conversations, or a string UUID
assert conv_id is None or isinstance(conv_id, str)
# =============================================================================
# Settings API
# =============================================================================
class TestSettingsAPI:
"""Settings API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("settings_get_output_language.yaml")
async def test_get_output_language(self):
"""Get current output language setting."""
async with vcr_client() as client:
language = await client.settings.get_output_language()
# Language may be None if not set, or a string like "en", "ja", "zh_Hans"
assert language is None or isinstance(language, str)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("settings_set_output_language.yaml")
async def test_set_output_language(self):
"""Set output language (then restore original)."""
async with vcr_client() as client:
# Get current language to restore later
original = await client.settings.get_output_language()
# Set to English
result = await client.settings.set_output_language("en")
assert result == "en" or result is None
# Restore original if it was set
if original:
await client.settings.set_output_language(original)
# =============================================================================
# Sharing API
# =============================================================================
class TestSharingAPI:
"""Sharing API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sharing_get_status.yaml")
async def test_get_status(self):
"""Get sharing status for a notebook."""
async with vcr_client() as client:
status = await client.sharing.get_status(READONLY_NOTEBOOK_ID)
assert status is not None
assert status.notebook_id == READONLY_NOTEBOOK_ID
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sharing_set_public.yaml")
async def test_set_public(self):
"""Toggle public sharing (restore original state)."""
async with vcr_client() as client:
# Get current status
original = await client.sharing.get_status(MUTABLE_NOTEBOOK_ID)
# Toggle to opposite
new_status = await client.sharing.set_public(
MUTABLE_NOTEBOOK_ID, not original.is_public
)
assert new_status.is_public != original.is_public
# Restore original state
await client.sharing.set_public(MUTABLE_NOTEBOOK_ID, original.is_public)
# =============================================================================
# Sources API - Additional Operations
# =============================================================================
class TestSourcesAdditionalAPI:
"""Additional sources API operations not covered in main TestSourcesAPI."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_add_file.yaml")
async def test_add_file(self, tmp_path):
"""Add a file source."""
# Create a test file
test_file = tmp_path / "vcr_test_document.txt"
test_file.write_text("This is a test document for VCR cassette recording.")
async with vcr_client() as client:
source = await client.sources.add_file(
MUTABLE_NOTEBOOK_ID,
str(test_file),
)
assert source is not None
assert source.id is not None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_check_freshness.yaml")
async def test_check_freshness(self):
"""Check source freshness."""
async with vcr_client() as client:
sources = await client.sources.list(READONLY_NOTEBOOK_ID)
if not sources:
pytest.skip("No sources available")
is_fresh = await client.sources.check_freshness(READONLY_NOTEBOOK_ID, sources[0].id)
assert isinstance(is_fresh, bool)
# The cassette shows API returns [] which should be interpreted as fresh
assert is_fresh is True, "Source in cassette should be fresh (API returned [])"
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_check_freshness_drive.yaml")
async def test_check_freshness_drive(self):
"""Check freshness for Drive source (different response format)."""
from notebooklm import SourceType
async with vcr_client() as client:
sources = await client.sources.list(MUTABLE_NOTEBOOK_ID)
if not sources:
pytest.skip("No sources available")
# Find a GOOGLE_DOCS source
drive_source = next((s for s in sources if s.kind == SourceType.GOOGLE_DOCS), None)
if not drive_source:
pytest.skip("No GOOGLE_DOCS source available")
is_fresh = await client.sources.check_freshness(MUTABLE_NOTEBOOK_ID, drive_source.id)
assert isinstance(is_fresh, bool)
# Drive sources return [[null, true, [source_id]]] when fresh
assert is_fresh is True, "Drive source should be fresh"
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_refresh.yaml")
async def test_refresh(self):
"""Refresh a source."""
from notebooklm import SourceType
async with vcr_client() as client:
sources = await client.sources.list(MUTABLE_NOTEBOOK_ID)
if not sources:
pytest.skip("No sources available")
# Find a WEB_PAGE source (text sources can't be refreshed)
url_source = next((s for s in sources if s.kind == SourceType.WEB_PAGE), None)
if not url_source:
pytest.skip("No WEB_PAGE source available for refresh")
result = await client.sources.refresh(MUTABLE_NOTEBOOK_ID, url_source.id)
# v0.8.0 (#1290): refresh() returns None on success (no exception)
assert result is None, "refresh() should return None on success"
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_rename.yaml")
async def test_rename(self):
"""Rename a source (then restore original name)."""
async with vcr_client() as client:
sources = await client.sources.list(MUTABLE_NOTEBOOK_ID)
if not sources:
pytest.skip("No sources available")
source = sources[0]
original_title = source.title
# Rename
renamed = await client.sources.rename(
MUTABLE_NOTEBOOK_ID, source.id, "VCR Test Renamed Source"
)
assert renamed.title == "VCR Test Renamed Source"
# Restore
await client.sources.rename(MUTABLE_NOTEBOOK_ID, source.id, original_title)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("sources_delete.yaml")
async def test_delete(self):
"""Delete a source (creates one first to delete)."""
async with vcr_client() as client:
# Create a source to delete
source = await client.sources.add_text(
MUTABLE_NOTEBOOK_ID,
title="VCR Delete Test Source",
content="This source will be deleted.",
)
assert source is not None
# Delete it
result = await client.sources.delete(MUTABLE_NOTEBOOK_ID, source.id)
assert result is None
# =============================================================================
# Notebooks API - Additional Operations
# =============================================================================
class TestNotebooksAdditionalAPI:
"""Additional notebooks API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_create.yaml")
async def test_create(self):
"""Create a new notebook."""
async with vcr_client() as client:
notebook = await client.notebooks.create("VCR Test Notebook")
assert notebook is not None
assert notebook.title == "VCR Test Notebook"
# Note: We don't delete it here to keep the cassette simple
# A separate delete test will clean up
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_delete.yaml")
async def test_delete(self):
"""Delete a notebook (creates one first)."""
async with vcr_client() as client:
# Create a notebook to delete
notebook = await client.notebooks.create("VCR Delete Test Notebook")
assert notebook is not None
# Delete it
result = await client.notebooks.delete(notebook.id)
assert result is None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebooks_remove_from_recent.yaml")
async def test_remove_from_recent(self):
"""Remove a notebook from recently viewed."""
async with vcr_client() as client:
# This just removes from the recent list, doesn't delete
await client.notebooks.remove_from_recent(MUTABLE_NOTEBOOK_ID)
# No return value to check - if it doesn't raise, it worked
# =============================================================================
# Notes API - Additional Operations
# =============================================================================
class TestNotesAdditionalAPI:
"""Additional notes API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notes_delete.yaml")
async def test_delete(self):
"""Delete a note (creates one first)."""
async with vcr_client() as client:
# Create a note to delete
note = await client.notes.create(
MUTABLE_NOTEBOOK_ID,
title="VCR Delete Test Note",
content="This note will be deleted.",
)
assert note is not None
# Delete it
result = await client.notes.delete(MUTABLE_NOTEBOOK_ID, note.id)
assert result is None
# =============================================================================
# Artifacts API - Additional Operations
# =============================================================================
class TestArtifactsAdditionalAPI:
"""Additional artifacts API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_rename.yaml")
async def test_rename(self):
"""Rename an artifact."""
async with vcr_client() as client:
# List artifacts to find one to rename
artifacts = await client.artifacts.list(MUTABLE_NOTEBOOK_ID)
if not artifacts:
pytest.skip("No artifacts available")
artifact = artifacts[0]
original_title = artifact.title
# v0.8.0 (#1362): return_object=False now runs the existence
# preflight too. Stub it as a hit (the artifact came from the list
# above, so it exists) so no extra LIST_ARTIFACTS round-trip fires
# and the existing cassette (rename RPC only) keeps replaying.
client.artifacts._listing.get_studio_only = AsyncMock(return_value=artifact)
# Rename, then restore the original name.
await client.artifacts.rename(
MUTABLE_NOTEBOOK_ID, artifact.id, "VCR Renamed Artifact", return_object=False
)
await client.artifacts.rename(
MUTABLE_NOTEBOOK_ID, artifact.id, original_title, return_object=False
)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_delete.yaml")
async def test_delete(self):
"""Delete an artifact."""
async with vcr_client() as client:
# List existing artifacts
artifacts = await client.artifacts.list(MUTABLE_NOTEBOOK_ID)
if not artifacts:
pytest.skip("No artifacts available to delete")
# Delete the first one
artifact_id = artifacts[0].id
deleted = await client.artifacts.delete(MUTABLE_NOTEBOOK_ID, artifact_id)
assert deleted is None
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_export_report.yaml")
async def test_export_report(self):
"""Export a report to Google Docs."""
async with vcr_client() as client:
# Find a completed report artifact
reports = await client.artifacts.list_reports(MUTABLE_NOTEBOOK_ID)
completed_reports = [r for r in reports if r.is_completed]
if not completed_reports:
pytest.skip("No completed report artifact available")
report = completed_reports[0]
# Export it to Google Docs
result = await client.artifacts.export_report(
MUTABLE_NOTEBOOK_ID, report.id, title="VCR Export Test"
)
assert result is not None
# =============================================================================
# Research API
# =============================================================================
class TestResearchAPI:
"""Research API operations."""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("research_start_fast.yaml")
async def test_start_fast(self):
"""Start fast web research."""
async with vcr_client() as client:
result = await client.research.start(
MUTABLE_NOTEBOOK_ID,
query="Python programming best practices",
source="web",
mode="fast",
)
assert result is not None
assert result.task_id
assert result.mode == "fast"
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("research_poll.yaml")
async def test_poll(self):
"""Poll research status."""
async with vcr_client() as client:
# Start research first
start_result = await client.research.start(
MUTABLE_NOTEBOOK_ID,
query="Machine learning fundamentals",
source="web",
mode="fast",
)
if not start_result or not start_result.task_id:
pytest.skip("Could not start research")
# Poll for results
result = await client.research.poll(
MUTABLE_NOTEBOOK_ID,
task_id=start_result.task_id,
)
assert result is not None
assert result.status
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("research_import_sources.yaml")
async def test_import_sources(self):
"""Import research sources."""
async with vcr_client() as client:
# Start research
start_result = await client.research.start(
MUTABLE_NOTEBOOK_ID,
query="Data science tutorials",
source="web",
mode="fast",
)
if not start_result or not start_result.task_id:
pytest.skip("Could not start research")
# Poll until we have sources (with timeout via cassette)
poll_result = await client.research.poll(
MUTABLE_NOTEBOOK_ID,
task_id=start_result.task_id,
)
if not poll_result.sources:
pytest.skip("No research sources found")
# Import first source
imported = await client.research.import_sources(
MUTABLE_NOTEBOOK_ID,
start_result.task_id,
poll_result.sources[:1],
)
assert isinstance(imported, list)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("research_start_deep.yaml")
async def test_start_deep(self):
"""Start deep web research."""
async with vcr_client() as client:
result = await client.research.start(
MUTABLE_NOTEBOOK_ID,
query="Artificial intelligence history",
source="web",
mode="deep",
)
assert result is not None
assert result.task_id
assert result.mode == "deep"