项目文件夹

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

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