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

278 lines
11 KiB
Python

"""Idempotency-policy regression tests for the note creation RPC family.
Tier 9 Wave 2 task ``b-research-notes`` (P0-3-notes). ``CREATE_NOTE`` is
classified as ``NON_IDEMPOTENT_NO_RETRY`` for both operation variants:
* ``"plain"`` — the default ``NoteService.create_note`` path. Params
``[notebook_id, "", [1], None, title]``. The server ignores the title
slot; ``UPDATE_NOTE`` follows up to set it. A 5xx mid-CREATE_NOTE
leaves no client-visible ``note_id``, so even a probe against
``GET_NOTES_AND_MIND_MAPS`` cannot reliably bind to the row this
specific call committed.
* ``"saved_from_chat"`` — the 7-element variant built by
``build_save_chat_as_note_params`` (issue #660). Title is settable
but the server may apply smart-title generation, breaking a
title-based probe. Content (the chat answer) is also not a safe
fingerprint — chat answers can recur if the user re-saves the same
result twice intentionally.
Both variants share the policy but are registered with explicit variant
keys so the registry documents the two distinct param shapes. Variant
strings: ``"plain"`` (5-element) and ``"saved_from_chat"`` (7-element).
The commit-lost-response model used by these tests:
1. Mock transport returns 502 on the first CREATE_NOTE request.
2. ``effective_disable_internal_retries=True`` (forced by the policy)
means the inner retry loop fires zero times — the 502 surfaces as
``ServerError``.
3. Test asserts exactly ONE request landed on the wire.
"""
from __future__ import annotations
import json
import httpx
import pytest
from notebooklm import NotebookLMClient, ServerError
from notebooklm._idempotency import IDEMPOTENCY_REGISTRY, IdempotencyPolicy
from notebooklm.rpc import RPCMethod
from notebooklm.types import AskResult, ChatReference
from tests._fixtures.kernel_test_helpers import install_http_client_for_test
# Mock-transport tests; no HTTP / no cassette.
pytestmark = pytest.mark.allow_no_vcr
# ---------------------------------------------------------------------------
# Helpers (mirror the pattern from test_idempotency_create.py)
# ---------------------------------------------------------------------------
def _wrb_response(rpc_id: str, payload) -> str:
"""Build a single-RPC batchexecute response body."""
inner = json.dumps(payload)
chunk = json.dumps([["wrb.fr", rpc_id, inner, None, None]])
return f")]}}'\n{len(chunk)}\n{chunk}\n"
def _make_client_with_transport(
transport: httpx.AsyncBaseTransport,
auth_tokens,
*,
server_error_max_retries: int = 3,
) -> NotebookLMClient:
"""Construct a ``NotebookLMClient`` wired to a mock httpx transport."""
client = NotebookLMClient(
auth_tokens,
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 — both CREATE_NOTE variants are NON_IDEMPOTENT_NO_RETRY
# ---------------------------------------------------------------------------
def test_create_note_default_variant_classified_non_idempotent() -> None:
"""The default ``(CREATE_NOTE, None)`` entry is NON_IDEMPOTENT_NO_RETRY.
The plain ``NoteService.create_note`` path uses the 5-element
params and is the default variant when callers don't pass an explicit
``operation_variant``.
"""
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.CREATE_NOTE)
assert entry.policy is IdempotencyPolicy.NON_IDEMPOTENT_NO_RETRY
assert entry.notes
def test_create_note_plain_variant_classified_non_idempotent() -> None:
"""The explicit ``"plain"`` variant is NON_IDEMPOTENT_NO_RETRY.
Same policy as the default; the variant key exists to document the
5-element param shape distinct from the 7-element saved-from-chat
shape. The decoupling lets a future change classify one variant
differently without touching the other.
"""
entry = IDEMPOTENCY_REGISTRY.get_entry(
RPCMethod.CREATE_NOTE,
operation_variant="plain",
)
assert entry.policy is IdempotencyPolicy.NON_IDEMPOTENT_NO_RETRY
assert entry.notes
def test_create_note_saved_from_chat_variant_classified_non_idempotent() -> None:
"""The ``"saved_from_chat"`` variant is NON_IDEMPOTENT_NO_RETRY.
Used by ``_chat.notes.save_chat_answer_as_note`` (issue #660). The
7-element params carry rich content; title-based probes break under
server-side smart-title generation, and chat-answer fingerprints are
not unique enough for safe dedupe.
"""
entry = IDEMPOTENCY_REGISTRY.get_entry(
RPCMethod.CREATE_NOTE,
operation_variant="saved_from_chat",
)
assert entry.policy is IdempotencyPolicy.NON_IDEMPOTENT_NO_RETRY
assert entry.notes
# ---------------------------------------------------------------------------
# Commit-lost-response behavior: exactly ONE CREATE_NOTE per call
# ---------------------------------------------------------------------------
async def test_create_note_plain_no_inner_retry_on_5xx(auth_tokens) -> None:
"""A 502 on ``notes.create()`` fires exactly ONE CREATE_NOTE POST.
``NotesAPI.create`` → ``NoteService.create_note`` issues
CREATE_NOTE (and on success, a follow-up UPDATE_NOTE). With the
plain variant classified NON_IDEMPOTENT_NO_RETRY, the inner retry
loop is disabled and the CREATE_NOTE 502 surfaces immediately. The
UPDATE_NOTE never runs because the create raised.
"""
notebook_id = "nb_test"
create_count = 0
update_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal create_count, update_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.CREATE_NOTE.value:
create_count += 1
return httpx.Response(502, text="bad gateway")
if rpc_id == RPCMethod.UPDATE_NOTE.value:
update_count += 1
return httpx.Response(200, text=_wrb_response(RPCMethod.UPDATE_NOTE.value, []))
return httpx.Response(404, text="unexpected")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens, server_error_max_retries=3)
try:
with pytest.raises(ServerError):
await client.notes.create(notebook_id, title="My Note", content="hello")
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert create_count == 1, (
f"expected exactly 1 CREATE_NOTE (NON_IDEMPOTENT_NO_RETRY), got {create_count}"
)
# CREATE_NOTE failed, so the follow-up UPDATE_NOTE must not have run.
assert update_count == 0, (
f"expected 0 UPDATE_NOTE after CREATE_NOTE failure, got {update_count}"
)
async def test_save_answer_as_note_no_inner_retry_on_5xx(auth_tokens) -> None:
"""A 502 on ``chat.save_answer_as_note()`` fires exactly ONE CREATE_NOTE POST.
The saved-from-chat variant is a single round-trip — no follow-up
UPDATE_NOTE. Classifying it NON_IDEMPOTENT_NO_RETRY forces the inner
retry loop off so the 502 surfaces after a single POST.
"""
notebook_id = "nb_test"
create_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal create_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.CREATE_NOTE.value:
create_count += 1
return httpx.Response(502, text="bad gateway")
return httpx.Response(404, text="unexpected")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens, server_error_max_retries=3)
ask_result = AskResult(
answer="The sky is blue [1].",
conversation_id="conv_test",
turn_number=1,
is_follow_up=False,
references=[
ChatReference(
source_id="src_a",
cited_text="The sky appears blue due to Rayleigh scattering.",
start_char=0,
end_char=48,
citation_number=1,
chunk_id="chunk_1",
passage_id=None,
),
],
)
try:
with pytest.raises(ServerError):
await client.chat.save_answer_as_note(notebook_id, ask_result, title="Chat note")
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert create_count == 1, (
f"expected exactly 1 CREATE_NOTE (saved_from_chat variant, "
f"NON_IDEMPOTENT_NO_RETRY), got {create_count}"
)
# ---------------------------------------------------------------------------
# Happy path stays unchanged — exactly ONE CREATE_NOTE on 200
# ---------------------------------------------------------------------------
async def test_create_note_happy_path_one_post(auth_tokens) -> None:
"""A successful ``notes.create()`` issues exactly ONE CREATE_NOTE.
Regression-protects the "no behavioral drift on the success path"
acceptance: classifying the RPC as NON_IDEMPOTENT_NO_RETRY must not
introduce extra requests when the call succeeds normally. The
follow-up UPDATE_NOTE is part of the normal create flow (one
persists the row, the other persists title + content).
"""
notebook_id = "nb_test"
create_count = 0
update_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal create_count, update_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.CREATE_NOTE.value:
create_count += 1
# Response shape: [[note_id, ...]] — see NoteService.create_note parse.
return httpx.Response(
200, text=_wrb_response(RPCMethod.CREATE_NOTE.value, [["note_xyz"]])
)
if rpc_id == RPCMethod.UPDATE_NOTE.value:
update_count += 1
return httpx.Response(200, text=_wrb_response(RPCMethod.UPDATE_NOTE.value, []))
return httpx.Response(404, text="unexpected")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
note = await client.notes.create(notebook_id, title="Happy", content="body")
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert note.id == "note_xyz"
assert create_count == 1
assert update_count == 1 # follow-up update is the success-path norm