Files
teng-lin--notebooklm-py/tests/integration/test_artifact_generation_idempotency.py
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

390 lines
15 KiB
Python

"""Idempotency tests for CREATE_ARTIFACT and GENERATE_MIND_MAP (P0-3).
These RPCs are mutating writes whose params carry no caller-supplied client
token (see the ``generate_*`` methods and the ``_artifact.payloads.build_*``
helpers in ``_artifacts.py`` for the CREATE_ARTIFACT / GENERATE_MIND_MAP
param shapes). Every positional slot is structural — type code, source ids,
language, config block — and the response is what surfaces a server-allocated
``artifact_id`` (``ArtifactsAPI._parse_generation_result`` in
``_artifacts.py`` reads ``result[0][0]``). Without a token slot,
the only safe retry policy is
:attr:`~notebooklm._idempotency.IdempotencyPolicy.PROBE_THEN_CREATE`, which
forces the transport's inner retry loop OFF so a 5xx after server-side
commit cannot trigger a duplicate write.
This file exercises that classification end-to-end:
1. The registry classifies both methods as PROBE_THEN_CREATE.
2. A 503 on the first POST surfaces as a single ``ServerError`` to the
caller — i.e. the shared transport does NOT silently re-POST.
This is the "commit-lost-response" safety property.
3. Happy-path calls still return the artifact / mind-map cleanly.
Wave 2 follow-up: a caller-owned ``idempotent_create`` wrapper around
``ArtifactsAPI._call_generate`` can later layer
probe-and-return semantics on top of this foundation (using
``client.artifacts.list()`` as the baseline-diff probe). That work is
out of scope here per the b-generation task spec.
"""
from __future__ import annotations
import json
import httpx
import pytest
from notebooklm import NotebookLMClient, RateLimitError, ServerError
from notebooklm._idempotency import IDEMPOTENCY_REGISTRY, IdempotencyPolicy
from notebooklm.rpc import RPCMethod
from tests._fixtures.kernel_test_helpers import install_http_client_for_test
# Mock-transport idempotency tests; no HTTP, no cassette. Opt out of the
# tier-enforcement hook in ``tests/integration/conftest.py``.
pytestmark = pytest.mark.allow_no_vcr
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _wrb_response(rpc_id: str, payload: object) -> str:
"""Build a single-RPC ``batchexecute`` response body.
Mirrors the on-the-wire format ``)]}}'\\n<len>\\n<chunk>\\n`` used by
the other mock-transport idempotency tests
(``tests/integration/concurrency/test_idempotency_create.py``).
"""
inner = json.dumps(payload)
chunk = json.dumps([["wrb.fr", rpc_id, inner, None, None]])
return f")]}}'\n{len(chunk)}\n{chunk}\n"
def _create_artifact_response(artifact_id: str) -> str:
"""Build a CREATE_ARTIFACT success response.
Shape mirrors the fixture used in ``test_artifacts_integration.py``::
[[artifact_id, "Audio Overview", "2024-01-05", None, 1]]
Decoded by ``_parse_generation_result``:
``result[0][0]`` → ``artifact_id``, ``result[0][4]`` → status code.
"""
return _wrb_response(
RPCMethod.CREATE_ARTIFACT.value,
[[artifact_id, "Audio Overview", "2024-01-05", None, 1]],
)
def _generate_mind_map_response(mind_map_json: str) -> str:
"""Build a GENERATE_MIND_MAP success response.
Shape: ``[[mind_map_json_str]]`` — decoded by
``ArtifactsAPI.generate_mind_map`` in ``_artifacts.py`` via
``result[0][0]``.
"""
return _wrb_response(RPCMethod.GENERATE_MIND_MAP.value, [[mind_map_json]])
def _get_notebook_response(notebook_id: str = "nb_test") -> str:
"""Build a GET_NOTEBOOK response with one source.
The ``generate_audio`` / ``generate_mind_map`` call paths fetch source
ids via ``GET_NOTEBOOK`` when ``source_ids=None`` is passed (the
default). Reused across every handler in this file.
"""
return _wrb_response(
RPCMethod.GET_NOTEBOOK.value,
[
[
"Test Notebook",
[[["src_001"], "Source 1", [None, 0], [None, 2]]],
notebook_id,
"📘",
None,
[None, None, None, None, None, [1704067200, 0]],
]
],
)
def _make_client_with_transport(
transport: httpx.AsyncBaseTransport,
auth_tokens: object,
*,
server_error_max_retries: int = 3,
) -> NotebookLMClient:
"""Construct a ``NotebookLMClient`` wired to a mock transport.
Bypasses the real ``ClientLifecycle.open()`` path (which would build a real
``httpx.AsyncClient`` + cookie jar) by stubbing in a pre-built
``AsyncClient`` whose transport is the test's mock. Mirrors the helper
in ``tests/integration/concurrency/test_idempotency_create.py``.
"""
client = NotebookLMClient(
auth_tokens, # type: ignore[arg-type]
server_error_max_retries=server_error_max_retries,
)
install_http_client_for_test(
client._collaborators.kernel,
httpx.AsyncClient(
transport=transport,
headers={
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
),
)
return client
def _rpc_id_in_request(request: httpx.Request) -> str | None:
"""Extract the ``rpcids=`` query param from a batchexecute request URL."""
for key, value in request.url.params.multi_items():
if key == "rpcids":
return value
return None
# ---------------------------------------------------------------------------
# Registry classification
# ---------------------------------------------------------------------------
class TestRegistryClassification:
"""Both methods MUST register as PROBE_THEN_CREATE at the (method, None) slot.
This is the contract that lets ``RpcExecutor`` resolve
``effective_disable_internal_retries=True`` for the call sites that
pass ``operation_variant=None`` (i.e. every CREATE_ARTIFACT and
GENERATE_MIND_MAP caller in ``_artifacts.py``).
"""
def test_create_artifact_classified_as_probe_then_create(self) -> None:
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.CREATE_ARTIFACT)
assert entry.policy is IdempotencyPolicy.PROBE_THEN_CREATE
def test_generate_mind_map_classified_as_probe_then_create(self) -> None:
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.GENERATE_MIND_MAP)
assert entry.policy is IdempotencyPolicy.PROBE_THEN_CREATE
def test_create_artifact_variant_none_explicit(self) -> None:
"""Passing ``operation_variant=None`` (the b1 plumbed call-site
kwarg) resolves to the same (method, None) PROBE_THEN_CREATE entry.
This guards against a future variant table being added for
CREATE_ARTIFACT and silently masking the PROBE_THEN_CREATE
classification for the no-variant path.
"""
entry = IDEMPOTENCY_REGISTRY.get_entry(
RPCMethod.CREATE_ARTIFACT,
operation_variant=None,
)
assert entry.policy is IdempotencyPolicy.PROBE_THEN_CREATE
def test_generate_mind_map_variant_none_explicit(self) -> None:
entry = IDEMPOTENCY_REGISTRY.get_entry(
RPCMethod.GENERATE_MIND_MAP,
operation_variant=None,
)
assert entry.policy is IdempotencyPolicy.PROBE_THEN_CREATE
# ---------------------------------------------------------------------------
# Commit-lost-response: 5xx must NOT trigger transport-level re-POST
# ---------------------------------------------------------------------------
async def test_create_artifact_503_does_not_re_post(auth_tokens) -> None:
"""A 503 on CREATE_ARTIFACT surfaces as ServerError after a single POST.
Before classification: the shared transport retry loop would re-POST
CREATE_ARTIFACT on the 5xx, duplicating the
server-side commit (the original audit P0-3 failure mode).
After classification (PROBE_THEN_CREATE):
``effective_disable_internal_retries=True`` is forced by the registry,
so the first 5xx surfaces immediately. Exactly ONE CREATE_ARTIFACT
POST hits the wire — no naive re-POST.
"""
create_count = 0
get_notebook_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal create_count, get_notebook_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.GET_NOTEBOOK.value:
# generate_audio fetches source ids; return one source.
get_notebook_count += 1
return httpx.Response(200, text=_get_notebook_response())
if rpc_id == RPCMethod.CREATE_ARTIFACT.value:
create_count += 1
return httpx.Response(503, text="service unavailable")
return httpx.Response(404, text=f"unexpected rpc: {rpc_id}")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
with pytest.raises(ServerError):
await client.artifacts.generate_audio(notebook_id="nb_test")
finally:
await client._collaborators.kernel.get_http_client().aclose()
# Exactly ONE CREATE_ARTIFACT POST despite ``server_error_max_retries=3``
# being configured: the PROBE_THEN_CREATE policy forced retries off.
assert create_count == 1, f"expected 1 CREATE_ARTIFACT POST, got {create_count}"
# Sanity-check the source-fetch did happen exactly once (pre-flight,
# unaffected by classification).
assert get_notebook_count == 1
async def test_create_artifact_429_does_not_re_post(auth_tokens) -> None:
"""A 429 on CREATE_ARTIFACT surfaces as ``RateLimitError`` after one POST.
``RuntimeTransport.perform_authed_post`` shares the same
``disable_internal_retries`` short-circuit for both 429 and 5xx paths
through ``RetryMiddleware``.
The PROBE_THEN_CREATE
classification must therefore prevent rate-limit retries from
silently re-issuing a committed-but-throttled-response request.
"""
create_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal create_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.GET_NOTEBOOK.value:
return httpx.Response(200, text=_get_notebook_response())
if rpc_id == RPCMethod.CREATE_ARTIFACT.value:
create_count += 1
return httpx.Response(429, text="rate limited")
return httpx.Response(404, text=f"unexpected rpc: {rpc_id}")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
with pytest.raises(RateLimitError):
await client.artifacts.generate_audio(notebook_id="nb_test")
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert create_count == 1, f"expected 1 CREATE_ARTIFACT POST, got {create_count}"
async def test_generate_mind_map_503_does_not_re_post(auth_tokens) -> None:
"""A 503 on GENERATE_MIND_MAP surfaces as ServerError after a single POST.
Symmetric to ``test_create_artifact_503_does_not_re_post``. The
GENERATE_MIND_MAP call site in ``ArtifactsAPI.generate_mind_map``
(``_artifacts.py``) must inherit the PROBE_THEN_CREATE classification
and disable the transport's inner retry loop.
"""
mind_map_count = 0
get_notebook_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal mind_map_count, get_notebook_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.GET_NOTEBOOK.value:
get_notebook_count += 1
return httpx.Response(200, text=_get_notebook_response())
if rpc_id == RPCMethod.GENERATE_MIND_MAP.value:
mind_map_count += 1
return httpx.Response(503, text="service unavailable")
return httpx.Response(404, text=f"unexpected rpc: {rpc_id}")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
with pytest.raises(ServerError):
await client.artifacts.generate_mind_map(notebook_id="nb_test")
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert mind_map_count == 1, f"expected 1 GENERATE_MIND_MAP POST, got {mind_map_count}"
assert get_notebook_count == 1
# ---------------------------------------------------------------------------
# Happy path: classification is invisible to a clean call
# ---------------------------------------------------------------------------
async def test_create_artifact_happy_path_still_returns_artifact(auth_tokens) -> None:
"""A clean 200 response under PROBE_THEN_CREATE classification still works.
Guards against a regression where forcing
``disable_internal_retries=True`` somehow changes the success path
(it should not — retries are only suppressed; the first successful
response is returned as-is).
"""
artifact_id = "artifact_happy"
create_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal create_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.GET_NOTEBOOK.value:
return httpx.Response(200, text=_get_notebook_response())
if rpc_id == RPCMethod.CREATE_ARTIFACT.value:
create_count += 1
return httpx.Response(200, text=_create_artifact_response(artifact_id))
return httpx.Response(404, text=f"unexpected rpc: {rpc_id}")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
status = await client.artifacts.generate_audio(notebook_id="nb_test")
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert status.task_id == artifact_id
assert create_count == 1
async def test_generate_mind_map_happy_path_still_returns_mind_map(auth_tokens) -> None:
"""A clean 200 response under PROBE_THEN_CREATE for GENERATE_MIND_MAP works.
Symmetric guard to ``test_create_artifact_happy_path_still_returns_artifact``.
The mind-map flow also persists a note after the RPC succeeds; we
stub the ``note_service.create_note`` seam on the artifacts API so
the test stays focused on the RPC-layer behavior and doesn't pull
in the full notes-API path. (Phase 5 moved the persistence call off
the module-level ``_mind_map.create_note`` shim and onto the
injected ``NoteService`` instance.)
"""
from unittest.mock import AsyncMock
from notebooklm.types import Note
mind_map_dict = {"name": "Test Mind Map", "children": []}
mind_map_json = json.dumps(mind_map_dict)
mind_map_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal mind_map_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.GET_NOTEBOOK.value:
return httpx.Response(200, text=_get_notebook_response())
if rpc_id == RPCMethod.GENERATE_MIND_MAP.value:
mind_map_count += 1
return httpx.Response(200, text=_generate_mind_map_response(mind_map_json))
return httpx.Response(404, text=f"unexpected rpc: {rpc_id}")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
stub_note = Note(id="note_stub", notebook_id="nb_test", title="Test Mind Map", content="")
client.artifacts._note_service.create_note = AsyncMock(return_value=stub_note) # type: ignore[method-assign]
try:
result = await client.artifacts.generate_mind_map(notebook_id="nb_test")
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert result.mind_map == mind_map_dict
assert result.note_id == "note_stub"
assert mind_map_count == 1