项目文件夹

文件
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

143 行
6.2 KiB
Python

"""Empty-result VCR cassettes — parser edge cases for empty state.
Three cassettes exercise the empty-collection code paths in:
* :mod:`notebooklm._sources` — ``SourcesAPI.list`` on a brand-new notebook
with zero sources must return ``[]``, not ``None`` and not raise.
* :mod:`notebooklm._artifacts` — ``ArtifactsAPI.list`` on a notebook with no
generated artifacts must return ``[]`` and must not propagate any error
from the mind-map sidecar.
* :mod:`notebooklm._research` — ``ResearchAPI.poll`` against a notebook that
has never started research must return the well-formed sentinel dict
``{"status": "no_research", "tasks": []}`` (the empty-state contract
documented on ``poll``'s docstring).
Empty results are a VALID state, not an error condition. Each test asserts
the parser yields an empty collection (the exact empty type the API returns,
NOT ``None`` and NOT an exception) so future drift toward "treat empty as
error" is caught at replay time.
Recording (LIVE):
NOTEBOOKLM_VCR_RECORD=1 uv run pytest \\
tests/integration/test_empty_results_vcr.py -v -s
Recording creates an ephemeral scratch notebook, runs the three reads
against it, then deletes the notebook. The notebook ID is therefore
incidental to replay — the cassettes are matched by RPC method + body,
not by notebook UUID.
"""
import pytest
from notebooklm import NotebookLMClient
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 (mirrors the
# pattern in tests/integration/test_vcr_comprehensive.py).
pytestmark = [pytest.mark.vcr, skip_no_cassettes]
class TestEmptyResults:
"""Replay tests for empty-state RPC responses.
All three tests share the same shape: open a VCR-decorated client, call
one collection-returning API, and assert the returned value is an empty
collection of the documented type. No notebook ID needs to be passed —
during replay VCR ignores the URL slug; during recording the orchestrator
passes a freshly-created scratch notebook ID (see this module's docstring).
"""
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("notebook_zero_sources.yaml")
async def test_zero_sources_returns_empty_list(self):
"""``SourcesAPI.list`` on a sources-less notebook returns ``[]``.
Exercises the empty-state path through :func:`SourcesAPI.list`.
Observed during recording: ``GET_NOTEBOOK`` against a brand-new
notebook elides the sources slot (returns ``None`` instead of an
empty list), which the parser folds into ``[]`` via the non-strict
``_handle_malformed_list_response`` branch — so a fresh notebook's
empty-source state is the SAME observable as a structurally
malformed response, and both must yield ``[]``. The default
``strict=False`` path is the relevant contract for empty-state
callers; ``strict=True`` is exercised elsewhere.
"""
notebook_id = _get_scratch_notebook_id()
auth = await get_vcr_auth()
async with NotebookLMClient(auth) as client:
sources = await client.sources.list(notebook_id)
# Empty list is a VALID state — not None, not an exception.
assert sources == []
assert isinstance(sources, list)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("artifacts_list_empty.yaml")
async def test_empty_artifacts_returns_empty_list(self):
"""``ArtifactsAPI.list`` on a notebook with zero artifacts returns ``[]``.
Covers both wings of the unified ``list`` implementation: the studio
``LIST_ARTIFACTS`` RPC (audio/video/reports/quizzes/...) AND the
mind-map sidecar via the injected
:class:`notebooklm._mind_map.NoteBackedMindMapService`. A brand-new
notebook has neither, so the merged return must be ``[]``.
"""
notebook_id = _get_scratch_notebook_id()
auth = await get_vcr_auth()
async with NotebookLMClient(auth) as client:
artifacts = await client.artifacts.list(notebook_id)
assert artifacts == []
assert isinstance(artifacts, list)
@pytest.mark.vcr
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette("research_poll_empty.yaml")
async def test_research_poll_no_tasks_returns_sentinel(self):
"""``ResearchAPI.poll`` with no research in flight returns the empty sentinel.
The empty-poll contract is documented on :meth:`ResearchAPI.poll`:
when ``POLL_RESEARCH`` returns an empty envelope, the API surface
yields ``{"status": "no_research", "tasks": []}`` — a stable dict
rather than ``None`` or an exception, so callers can poll in a loop
without special-casing "haven't started yet".
"""
notebook_id = _get_scratch_notebook_id()
auth = await get_vcr_auth()
async with NotebookLMClient(auth) as client:
result = await client.research.poll(notebook_id)
# Empty-state sentinel, not None and not an exception. The typed
# ResearchTask compares equal to the historical status string and its
# to_public_dict mirrors the old ``{"status": ..., "tasks": []}`` shape.
assert result.status == "no_research"
assert result.tasks == ()
assert result.to_public_dict() == {"status": "no_research", "tasks": []}
# =============================================================================
# Scratch notebook plumbing
# =============================================================================
def _get_scratch_notebook_id() -> str:
"""Return the notebook ID to pass through the three API calls.
During RECORDING the test orchestrator sets
``NOTEBOOKLM_EMPTY_SCRATCH_NOTEBOOK_ID`` to the freshly-minted scratch
notebook UUID (see this module's docstring). During REPLAY no scratch
notebook exists — VCR replays the recorded responses regardless of
notebook ID, so we just return a placeholder UUID that lets the request
builder run without raising.
"""
import os
return os.environ.get(
"NOTEBOOKLM_EMPTY_SCRATCH_NOTEBOOK_ID",
"00000000-0000-0000-0000-000000000000",
)