teng-lin--notebooklm-py
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
463 行
18 KiB
Python
463 行
18 KiB
Python
"""Integration tests for the authed-post middleware chain.
|
|
|
|
:func:`notebooklm._middleware.core.build_chain` is wired by
|
|
:func:`compose_client_internals` against the chain leaf on
|
|
:class:`MiddlewareChainHost`
|
|
(:meth:`MiddlewareChainHost._authed_post_chain_terminal`), which
|
|
consumes the populated ``RpcRequest.url`` / ``headers`` / ``body``
|
|
envelope and delegates directly to ``Kernel.post`` — the transport
|
|
seam under both :meth:`RuntimeTransport.perform_authed_post` AND
|
|
``RpcExecutor._execute_once``. The ``NotebookLMClient._perform_authed_post``
|
|
compatibility forward was deleted in Wave 11c of session-decoupling;
|
|
tests now drive the canonical collaborator method directly.
|
|
|
|
These tests verify the wiring contract from
|
|
ADR-0009 §"RpcRequest.context keys":
|
|
|
|
1. Both call paths (``RuntimeTransport.perform_authed_post`` directly
|
|
and the ``RpcExecutor._execute_once`` keyword shape) flow through
|
|
the chain terminal to the transport.
|
|
2. ``RpcRequest.context`` carries ``build_request`` / ``log_label`` /
|
|
``disable_internal_retries`` for retry/rebuild metadata while the terminal
|
|
reads the envelope itself.
|
|
3. The leaf returns an :class:`RpcResponse` wrapping the
|
|
:class:`httpx.Response` from the transport.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from notebooklm._middleware.core import (
|
|
Middleware,
|
|
NextCall,
|
|
RpcRequest,
|
|
RpcResponse,
|
|
build_chain,
|
|
)
|
|
from notebooklm._transport_errors import TransportServerError
|
|
from notebooklm.client import NotebookLMClient
|
|
from tests._helpers.client_factory import build_client_shell_for_tests
|
|
|
|
_UNSET = object()
|
|
|
|
|
|
def _make_core() -> NotebookLMClient:
|
|
"""Build a ``NotebookLMClient`` instance without opening an HTTP client.
|
|
|
|
``NotebookLMClient.__init__`` is event-loop-agnostic, so we can construct an
|
|
instance in synchronous test setup. Tests replace ``Kernel.post`` directly
|
|
so no real HTTP call fires.
|
|
"""
|
|
auth = MagicMock()
|
|
auth.storage_path = None
|
|
auth.authuser = 0
|
|
auth.account_email = None
|
|
auth.csrf_token = "csrf-token"
|
|
auth.session_id = "session-id"
|
|
return build_client_shell_for_tests(auth=auth)
|
|
|
|
|
|
class FakeKernelPost:
|
|
"""Programmable stub for ``Kernel.post``."""
|
|
|
|
def __init__(self, response: httpx.Response | None = None) -> None:
|
|
self.response = response or httpx.Response(status_code=200, content=b"")
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
@property
|
|
def call_count(self) -> int:
|
|
return len(self.calls)
|
|
|
|
async def post(
|
|
self,
|
|
url: str,
|
|
*,
|
|
headers: Any,
|
|
body: bytes,
|
|
read_timeout: float | None = None,
|
|
max_response_bytes: int | None | object = _UNSET,
|
|
) -> httpx.Response:
|
|
call = {"url": url, "headers": headers, "body": body, "read_timeout": read_timeout}
|
|
if max_response_bytes is not _UNSET:
|
|
call["max_response_bytes"] = max_response_bytes
|
|
self.calls.append(call)
|
|
return self.response
|
|
|
|
|
|
def _swap_kernel_post(core: NotebookLMClient, fake: FakeKernelPost) -> None:
|
|
core._collaborators.kernel.post = fake.post # type: ignore[method-assign]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_routes_perform_authed_post_to_transport() -> None:
|
|
"""``RuntimeTransport.perform_authed_post`` flows through the chain.
|
|
|
|
Covers direct callers of ``RuntimeTransport.perform_authed_post``: the chat
|
|
path in :func:`notebooklm._chat.transport.chat_aware_authed_post` and any
|
|
first-party caller via ``client._composed.transport.perform_authed_post``.
|
|
"""
|
|
expected_response = httpx.Response(status_code=200, content=b"chain-routed")
|
|
fake = FakeKernelPost(response=expected_response)
|
|
core = _make_core()
|
|
_swap_kernel_post(core, fake)
|
|
|
|
def build_request(snapshot: Any) -> tuple[str, bytes, dict[str, str] | None]:
|
|
return ("https://fake/url", b"body", None)
|
|
|
|
response = await core._composed.transport.perform_authed_post(
|
|
build_request=build_request,
|
|
log_label="test-log-label",
|
|
disable_internal_retries=False,
|
|
)
|
|
|
|
assert response is expected_response
|
|
assert fake.call_count == 1
|
|
call = fake.calls[0]
|
|
assert call["url"] == "https://fake/url"
|
|
assert call["headers"] == {}
|
|
assert call["body"] == b"body"
|
|
assert call.get("read_timeout") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_routes_rpc_executor_path_to_transport() -> None:
|
|
"""``RpcExecutor._execute_once`` → ``perform_authed_post`` flows through the chain too.
|
|
|
|
``RpcExecutor._execute_once`` calls
|
|
``self._transport.perform_authed_post(...)`` (Wave 4 of
|
|
session-decoupling: the executor takes :class:`RuntimeTransport`
|
|
directly instead of reaching through NotebookLMClient). Routing both paths
|
|
through one seam is the whole point of wiring at
|
|
``perform_authed_post`` rather than at each call site.
|
|
|
|
We exercise the route by calling ``perform_authed_post`` with the
|
|
keyword shape ``RpcExecutor._execute_once`` uses (the
|
|
``log_label=f"RPC {method.name}"`` template)
|
|
and asserting the chain leaf hands those exact kwargs to the
|
|
transport. We do NOT spin up a full ``RpcExecutor`` here because that
|
|
pulls in the idempotency registry and encoder fixtures; the seam
|
|
invariant is "the chain receives whatever ``perform_authed_post``
|
|
receives," which a direct call validates without the extra surface.
|
|
"""
|
|
expected_response = httpx.Response(status_code=200, content=b"rpc-path")
|
|
fake = FakeKernelPost(response=expected_response)
|
|
core = _make_core()
|
|
_swap_kernel_post(core, fake)
|
|
|
|
def build_request(snapshot: Any) -> tuple[str, bytes, dict[str, str] | None]:
|
|
return ("https://fake/rpc", b"rpc-body", {"X-Goog-AuthUser": "0"})
|
|
|
|
response = await core._composed.transport.perform_authed_post(
|
|
build_request=build_request,
|
|
log_label="RPC LIST_NOTEBOOKS",
|
|
disable_internal_retries=True,
|
|
)
|
|
|
|
assert response is expected_response
|
|
assert fake.call_count == 1
|
|
call = fake.calls[0]
|
|
assert call["url"] == "https://fake/rpc"
|
|
assert call["headers"] == {"X-Goog-AuthUser": "0"}
|
|
assert call["body"] == b"rpc-body"
|
|
assert call.get("read_timeout") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_terminal_reads_context_keys() -> None:
|
|
"""``RpcRequest.context`` carries the three keys the terminal reads.
|
|
|
|
Drives the terminal adapter directly with a hand-built ``RpcRequest``
|
|
so we can assert the contract independently of
|
|
:meth:`RuntimeTransport.perform_authed_post`'s context-construction code.
|
|
This is what every middleware PR 12.3–12.8 will rely on when it
|
|
builds a chain over ``[*middlewares, ...]`` and lets the leaf adapt
|
|
the request into a transport call.
|
|
"""
|
|
expected_response = httpx.Response(status_code=204, content=b"")
|
|
fake = FakeKernelPost(response=expected_response)
|
|
core = _make_core()
|
|
_swap_kernel_post(core, fake)
|
|
|
|
request = RpcRequest(
|
|
url="https://fake/ctx",
|
|
headers={"X-Test": "yes"},
|
|
body=b"ctx-body",
|
|
context={
|
|
"log_label": "context-test",
|
|
"disable_internal_retries": False,
|
|
},
|
|
)
|
|
|
|
result = await core._composed.chain_host._authed_post_chain_terminal(request)
|
|
|
|
assert isinstance(result, RpcResponse)
|
|
assert result.response is expected_response
|
|
# The ``RpcResponse.context`` propagates the same dict the request
|
|
# carried, so middlewares above the leaf can read additions a deeper
|
|
# link made. The terminal adapter leaves the dict unchanged.
|
|
assert result.context is request.context
|
|
assert fake.call_count == 1
|
|
assert fake.calls[0] == {
|
|
"url": "https://fake/ctx",
|
|
"headers": {"X-Test": "yes"},
|
|
"body": b"ctx-body",
|
|
"read_timeout": None,
|
|
}
|
|
assert "max_response_bytes" not in fake.calls[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_terminal_disable_internal_retries_defaults_false() -> None:
|
|
"""When ``context`` omits ``disable_internal_retries`` the leaf reads ``False``.
|
|
|
|
``perform_authed_post`` always populates the key, but the leaf
|
|
defends against a missing entry so middlewares that build a request
|
|
without the key cannot trip the leaf with a ``KeyError``.
|
|
"""
|
|
fake = FakeKernelPost()
|
|
core = _make_core()
|
|
_swap_kernel_post(core, fake)
|
|
|
|
request = RpcRequest(
|
|
url="https://fake/no-retry-flag",
|
|
headers={},
|
|
body=b"",
|
|
context={
|
|
"log_label": "default-flag",
|
|
},
|
|
)
|
|
|
|
await core._composed.chain_host._authed_post_chain_terminal(request)
|
|
|
|
assert fake.call_count == 1
|
|
assert fake.calls[0]["url"] == "https://fake/no-retry-flag"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_terminal_log_label_defaults_for_direct_calls() -> None:
|
|
"""Direct terminal calls without context metadata still map errors safely."""
|
|
core = _make_core()
|
|
|
|
async def raise_network_error(
|
|
url: str,
|
|
*,
|
|
headers: Any,
|
|
body: bytes,
|
|
read_timeout: float | None = None,
|
|
max_response_bytes: int | None | object = _UNSET,
|
|
) -> httpx.Response:
|
|
request = httpx.Request("POST", url, headers=dict(headers), content=body)
|
|
raise httpx.RequestError("boom", request=request)
|
|
|
|
core._collaborators.kernel.post = raise_network_error # type: ignore[method-assign]
|
|
request = RpcRequest(
|
|
url="https://fake/no-log-label",
|
|
headers={},
|
|
body=b"",
|
|
context={},
|
|
)
|
|
|
|
with pytest.raises(TransportServerError, match="<unknown-chain-call> network error"):
|
|
await core._composed.chain_host._authed_post_chain_terminal(request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_seeded_with_final_adr_009_ordering() -> None:
|
|
"""``NotebookLMClient.__init__`` seeds the chain with the FINAL ADR-0009 ordering.
|
|
|
|
PR 12.3 landed ``TracingMiddleware`` at the innermost position; PR 12.4
|
|
prepended ``MetricsMiddleware``; PR 12.5 prepended ``DrainMiddleware``
|
|
outermost; PR 12.6 inserted ``ErrorInjectionMiddleware`` between
|
|
Metrics and Tracing; PR 12.7 inserted ``RetryMiddleware`` between
|
|
Metrics and ErrorInjection; PR 12.8 inserted ``AuthRefreshMiddleware``
|
|
between Retry and ErrorInjection; PR 12.9 inserted
|
|
``SemaphoreMiddleware`` between Metrics and Retry (codex catch — see
|
|
ADR-0009 close-out notes). The list now reads the final ADR-0009
|
|
ordering
|
|
``[Drain, Metrics, Semaphore, Retry, AuthRefresh, ErrorInjection, Tracing]``
|
|
(outermost → innermost).
|
|
|
|
Order rationale (per ADR-0009):
|
|
- Drain outermost — every in-flight call counts toward shutdown wait
|
|
- Metrics outside Semaphore — latency includes queue wait
|
|
- Semaphore outside Retry — retry attempts stay in one slot
|
|
- Retry outside AuthRefresh — orthogonal failure modes
|
|
- AuthRefresh outside ErrorInjection — test-injected 401s exercise refresh
|
|
- ErrorInjection inside Retry — synthetic transient failures trigger retry
|
|
- Tracing innermost — logs actual HTTP attempts including retries
|
|
|
|
The list is exposed as ``self._middlewares`` so the cleanup audit can
|
|
verify ordering by inspecting the production attribute directly.
|
|
"""
|
|
from notebooklm._middleware.auth_refresh import AuthRefreshMiddleware
|
|
from notebooklm._middleware.drain import DrainMiddleware
|
|
from notebooklm._middleware.error_injection import ErrorInjectionMiddleware
|
|
from notebooklm._middleware.metrics import MetricsMiddleware
|
|
from notebooklm._middleware.retry import RetryMiddleware
|
|
from notebooklm._middleware.semaphore import SemaphoreMiddleware
|
|
from notebooklm._middleware.tracing import TracingMiddleware
|
|
|
|
core = _make_core()
|
|
assert len(core._composed.middlewares) == 7
|
|
assert isinstance(core._composed.middlewares[0], DrainMiddleware)
|
|
assert isinstance(core._composed.middlewares[1], MetricsMiddleware)
|
|
assert isinstance(core._composed.middlewares[2], SemaphoreMiddleware)
|
|
assert isinstance(core._composed.middlewares[3], RetryMiddleware)
|
|
assert isinstance(core._composed.middlewares[4], AuthRefreshMiddleware)
|
|
assert isinstance(core._composed.middlewares[5], ErrorInjectionMiddleware)
|
|
assert isinstance(core._composed.middlewares[6], TracingMiddleware)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_with_test_middleware_observes_request_and_response() -> None:
|
|
"""A test middleware can observe the request and response around the leaf.
|
|
|
|
Demonstrates the contract middleware components rely on: insert a
|
|
middleware into a chain, drive a request through, and assert the middleware
|
|
saw both the inbound request and the outbound response. This is the wire-up
|
|
smoke test for middleware composition.
|
|
|
|
Builds the chain locally (rather than mutating ``core._composed.middlewares``
|
|
in-place) because production code does not yet support hot-swapping
|
|
the chain — that's a PR 12.3 concern when ``TracingMiddleware`` lands.
|
|
"""
|
|
observed: dict[str, Any] = {}
|
|
|
|
async def observer(request: RpcRequest, next_call: NextCall) -> RpcResponse:
|
|
observed["request"] = request
|
|
response = await next_call(request)
|
|
observed["response"] = response
|
|
return response
|
|
|
|
expected_response = httpx.Response(status_code=200, content=b"observed")
|
|
fake = FakeKernelPost(response=expected_response)
|
|
core = _make_core()
|
|
_swap_kernel_post(core, fake)
|
|
|
|
# Build a chain with one observer middleware around the production
|
|
# terminal. This per-test composition validates the leaf's contract
|
|
# against ``build_chain`` without mutating ``NotebookLMClient.__init__``'s
|
|
# production chain.
|
|
chain: NextCall = build_chain([observer], core._composed.chain_host._authed_post_chain_terminal)
|
|
|
|
request = RpcRequest(
|
|
url="https://fake/observe",
|
|
headers={},
|
|
body=b"",
|
|
context={
|
|
"log_label": "observer-test",
|
|
"disable_internal_retries": False,
|
|
},
|
|
)
|
|
|
|
result = await chain(request)
|
|
|
|
assert observed["request"] is request
|
|
assert isinstance(observed["response"], RpcResponse)
|
|
assert observed["response"].response is expected_response
|
|
assert result.response is expected_response
|
|
assert fake.call_count == 1
|
|
assert fake.calls[0]["url"] == "https://fake/observe"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_terminal_forwards_read_timeout_context() -> None:
|
|
"""Per-request read timeout context reaches the concrete streaming POST."""
|
|
expected_response = httpx.Response(status_code=200, content=b"read-timeout")
|
|
fake = FakeKernelPost(response=expected_response)
|
|
core = _make_core()
|
|
_swap_kernel_post(core, fake)
|
|
|
|
request = RpcRequest(
|
|
url="https://fake/read-timeout",
|
|
headers={},
|
|
body=b"",
|
|
context={
|
|
"log_label": "read-timeout-test",
|
|
"read_timeout": 123.0,
|
|
},
|
|
)
|
|
|
|
result = await core._composed.chain_host._authed_post_chain_terminal(request)
|
|
|
|
assert result.response is expected_response
|
|
assert fake.calls[0].get("read_timeout") == 123.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chain_terminal_forwards_max_response_bytes_context() -> None:
|
|
"""Per-request response byte cap context reaches the concrete streaming POST."""
|
|
expected_response = httpx.Response(status_code=200, content=b"max-response")
|
|
fake = FakeKernelPost(response=expected_response)
|
|
core = _make_core()
|
|
_swap_kernel_post(core, fake)
|
|
|
|
request = RpcRequest(
|
|
url="https://fake/max-response",
|
|
headers={},
|
|
body=b"",
|
|
context={
|
|
"log_label": "max-response-test",
|
|
"max_response_bytes": 123,
|
|
},
|
|
)
|
|
|
|
result = await core._composed.chain_host._authed_post_chain_terminal(request)
|
|
|
|
assert result.response is expected_response
|
|
assert fake.calls[0].get("max_response_bytes") == 123
|
|
|
|
|
|
def test_build_chain_empty_returns_terminal_unchanged() -> None:
|
|
""":func:`build_chain` returns the terminal unchanged when ``middlewares`` is empty.
|
|
|
|
Pins the contract that ``_middleware.build_chain([], terminal) is terminal``
|
|
so the ``chain_host._authed_post_chain is
|
|
chain_host._authed_post_chain_terminal`` invariant from
|
|
:func:`test_chain_is_empty_by_default` does not silently flip if
|
|
``build_chain``'s identity behavior changes. Synchronous test —
|
|
no event-loop overhead.
|
|
"""
|
|
|
|
async def terminal(request: RpcRequest) -> RpcResponse:
|
|
return RpcResponse(
|
|
response=httpx.Response(status_code=200, content=b""),
|
|
context=request.context,
|
|
)
|
|
|
|
middlewares: list[Middleware] = []
|
|
chain = build_chain(middlewares, terminal)
|
|
assert chain is terminal
|
|
|
|
|
|
def test_perform_authed_post_signature_unchanged() -> None:
|
|
"""The keyword-only signature of ``perform_authed_post`` is unchanged.
|
|
|
|
Many call sites pass the three kwargs by name, including the RPC executor,
|
|
chat transport, and integration tests. The chain wiring inside the body
|
|
must NOT change the public-ish signature; this guard catches an
|
|
accidental rename. The NotebookLMClient-level ``_perform_authed_post`` forward
|
|
was deleted in Wave 11c of session-decoupling; the signature contract
|
|
now lives on the canonical collaborator method
|
|
(``RuntimeTransport.perform_authed_post``).
|
|
"""
|
|
import inspect
|
|
|
|
from notebooklm._runtime.transport import RuntimeTransport
|
|
|
|
sig = inspect.signature(RuntimeTransport.perform_authed_post)
|
|
params = sig.parameters
|
|
assert "build_request" in params
|
|
assert "log_label" in params
|
|
assert "disable_internal_retries" in params
|
|
# All three are keyword-only — the ``*`` separator in the production
|
|
# signature is what makes this true.
|
|
assert params["build_request"].kind is inspect.Parameter.KEYWORD_ONLY
|
|
assert params["log_label"].kind is inspect.Parameter.KEYWORD_ONLY
|
|
assert params["disable_internal_retries"].kind is inspect.Parameter.KEYWORD_ONLY
|