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
267 lines
9.5 KiB
Python
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
|