Files
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

267 lines
9.5 KiB
Python

"""Unit tests for :mod:`notebooklm._runtime.helpers`.
Currently focused on :func:`resolve_sleep` — the shared helper that
replaces the duplicated ``_resolve_sleep`` methods previously defined on
both :class:`RetryMiddleware` and :class:`AuthRefreshMiddleware` (audit
finding CC1).
The contract pinned here:
- **Injected callable wins** — ``resolve_sleep(injected)`` returns the
injected callable verbatim when it is not ``None``.
- **None resolves to ``asyncio.sleep`` at call time** — capturing the
module-level attribute on each invocation rather than at import time.
- **Late binding through middlewares** — both ``RetryMiddleware`` and
``AuthRefreshMiddleware`` observe a ``monkeypatch.setattr`` of
``asyncio.sleep`` because they call ``resolve_sleep(self._sleep)``
per backoff/retry, and ``resolve_sleep`` re-reads the ``asyncio``
module attribute every time.
This is the regression test for the audit-CC1 extraction: the duplicated
helper is gone, but the late-binding semantics tests previously relied
on (``monkeypatch.setattr("asyncio.sleep", ...)``) still work.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
import httpx
import pytest
from notebooklm._middleware.auth_refresh import AuthRefreshMiddleware
from notebooklm._middleware.core import NextCall, RpcRequest, RpcResponse, build_chain
from notebooklm._middleware.retry import RetryMiddleware
from notebooklm._runtime.helpers import is_auth_error, resolve_sleep
from notebooklm._transport_errors import TransportServerError
from notebooklm.rpc import AuthError, RPCError, ServerError
from tests._fixtures.chain import make_request
# ---------------------------------------------------------------------------
# Direct unit tests for resolve_sleep
# ---------------------------------------------------------------------------
def test_resolve_sleep_returns_injected_when_provided() -> None:
"""A non-``None`` injection is returned verbatim — no proxying."""
async def fake(_seconds: float) -> None:
return None
assert resolve_sleep(fake) is fake
def test_resolve_sleep_returns_asyncio_sleep_when_none() -> None:
"""``None`` resolves to the canonical ``asyncio.sleep``."""
assert resolve_sleep(None) is asyncio.sleep
def test_resolve_sleep_late_binds_asyncio_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""``monkeypatch.setattr('asyncio.sleep', ...)`` is observed at call time.
The helper re-reads ``asyncio.sleep`` from the ``asyncio`` module global
on every invocation, so patches that mutate the singleton ``asyncio``
module's ``sleep`` attribute reach every caller. Capturing
``asyncio.sleep`` at import or construction time would freeze the binding
and silently bypass the patch — that's the bug this helper exists to
prevent.
"""
async def fake(_seconds: float) -> None:
return None
monkeypatch.setattr("asyncio.sleep", fake)
assert resolve_sleep(None) is fake
# ---------------------------------------------------------------------------
# Direct unit tests for is_auth_error
# ---------------------------------------------------------------------------
def test_is_auth_error_accepts_typed_auth_error() -> None:
assert is_auth_error(AuthError("Authentication expired")) is True
@pytest.mark.parametrize("status", [400, 401, 403])
def test_is_auth_error_accepts_auth_http_statuses(status: int) -> None:
assert is_auth_error(_auth_error_http(status)) is True
@pytest.mark.parametrize("rpc_code", [401, 403, 16, "UNAUTHENTICATED"])
def test_is_auth_error_accepts_explicit_rpc_auth_codes(rpc_code: int | str) -> None:
assert is_auth_error(RPCError("auth service failure", rpc_code=rpc_code)) is True
def test_is_auth_error_accepts_explicit_rpc_status_code() -> None:
error = RPCError("wrapped auth status")
error.status_code = 401
assert is_auth_error(error) is True
def test_is_auth_error_accepts_explicit_rpc_status_label() -> None:
error = RPCError("wrapped auth status")
error.status = "UNAUTHENTICATED"
assert is_auth_error(error) is True
@pytest.mark.parametrize(
"message",
[
"Authentication summary failed for a malformed response",
"Session expired while processing a non-auth artifact status",
"Unauthorized access to this notebook",
"Please login before retrying a quota-limited action",
],
)
def test_is_auth_error_ignores_auth_words_without_status_or_code(message: str) -> None:
assert is_auth_error(RPCError(message)) is False
def test_is_auth_error_does_not_promote_server_error_with_auth_code() -> None:
error = ServerError("server auth subsystem failed", status_code=500, rpc_code=401)
assert is_auth_error(error) is False
def test_is_auth_error_prefers_non_auth_rpc_code_over_legacy_message() -> None:
error = RPCError(
"Authentication required. Run 'notebooklm login' to re-authenticate.",
rpc_code=500,
)
assert is_auth_error(error) is False
@pytest.mark.parametrize("rpc_code", ["", " ", "9" * 257])
def test_is_auth_error_ignores_empty_or_large_rpc_code_before_legacy_message(
rpc_code: str,
) -> None:
error = RPCError("Authentication expired", rpc_code=rpc_code)
assert is_auth_error(error) is True
# ---------------------------------------------------------------------------
# End-to-end late-binding through both middlewares
# ---------------------------------------------------------------------------
def _server_error(status: int = 503) -> TransportServerError:
request = httpx.Request("POST", "https://example.test/x")
response = httpx.Response(status, request=request)
original = httpx.HTTPStatusError(f"HTTP {status}", request=request, response=response)
return TransportServerError(
f"server error (HTTP {status})",
original=original,
response=response,
status_code=status,
)
def _auth_error_http(status: int = 401) -> httpx.HTTPStatusError:
request = httpx.Request("POST", "https://example.test/x")
response = httpx.Response(status, request=request)
return httpx.HTTPStatusError(f"HTTP {status}", request=request, response=response)
def _ok() -> httpx.Response:
request = httpx.Request("POST", "https://example.test/x")
return httpx.Response(200, request=request, content=b"ok")
def _scripted_terminal(behaviors: list[object]) -> NextCall:
iterator = iter(behaviors)
async def terminal(request: RpcRequest) -> RpcResponse:
nxt = next(iterator)
if isinstance(nxt, BaseException):
raise nxt
assert isinstance(nxt, httpx.Response)
return RpcResponse(response=nxt, context=request.context)
return terminal
@pytest.mark.asyncio
async def test_retry_middleware_observes_monkeypatched_asyncio_sleep(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""RetryMiddleware honors a ``monkeypatch.setattr('asyncio.sleep', ...)``.
The middleware is constructed without a ``sleep=`` injection, so the
no-jitter / no-real-time backoff comes from late-binding to the
monkeypatched ``asyncio.sleep`` instead of the real coroutine.
"""
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
# No ``sleep=`` injection — the middleware must resolve ``asyncio.sleep``
# at call time via the shared helper.
middleware = RetryMiddleware(rate_limit_max_retries=0, server_error_max_retries=1)
chain = build_chain([middleware], _scripted_terminal([_server_error(), _ok()]))
response = await chain(make_request())
assert response.response.status_code == 200
assert len(sleeps) == 1, "expected exactly one backoff sleep before retry"
@pytest.mark.asyncio
async def test_auth_refresh_middleware_observes_monkeypatched_asyncio_sleep(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""AuthRefreshMiddleware honors a ``monkeypatch.setattr('asyncio.sleep', ...)``.
Forces a post-refresh sleep by setting ``refresh_retry_delay`` > 0 and
asserts the patched ``asyncio.sleep`` (not the real one) is invoked.
"""
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
refresh_calls: list[int] = []
async def refresh() -> None:
refresh_calls.append(1)
def _live_delay() -> float:
return 0.25
middleware = AuthRefreshMiddleware(
refresh_callable=refresh,
is_auth_error=is_auth_error,
refresh_callback_enabled=lambda: True,
refresh_retry_delay=_live_delay,
# No ``sleep=`` injection — must late-bind to monkeypatched
# ``asyncio.sleep`` via the shared helper.
)
chain = build_chain([middleware], _scripted_terminal([_auth_error_http(401), _ok()]))
response = await chain(make_request())
assert response.response.status_code == 200
assert refresh_calls == [1]
assert sleeps == [pytest.approx(0.25)], (
"expected exactly one post-refresh sleep at the configured delay, "
"routed through the monkeypatched asyncio.sleep"
)
@pytest.mark.asyncio
async def test_resolve_sleep_directly_invokable_is_awaitable() -> None:
"""Sanity-check that ``resolve_sleep(None)(0)`` is awaitable.
Guards against accidentally returning a non-coroutine callable from the
helper. We pass ``0`` so the test runs instantly with no real sleep.
"""
result = resolve_sleep(None)(0)
assert isinstance(result, Awaitable)
await result