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

406 lines
16 KiB
Python

"""Unit tests for :func:`notebooklm._chat.transport.chat_aware_authed_post`.
Exercises the chat-domain error-mapping seam over the generic transport
primitives. Each test injects a stub ``transport`` whose
``perform_authed_post`` raises one of the transport-layer exceptions
(or the raw ``httpx`` status error) and asserts the function maps the
failure to the expected ``ChatError`` / ``NetworkError`` shape, message,
and exception chain.
As of Tier-12 PR 12.5 the drain-tracking bookkeeping
(``_begin_transport_post`` / ``_finish_transport_post``) has moved into
``DrainMiddleware`` at the outermost chain position around
``RuntimeTransport.perform_authed_post``. ``chat_aware_authed_post`` no
longer brackets its own transport call with explicit drain calls —
admission and finalization are middleware concerns now. The tests
correspondingly stub only ``perform_authed_post`` on the transport.
As of Wave 8 of the session-decoupling plan (ADR-0014 Rule 2 Corollary),
``chat_aware_authed_post`` takes the :class:`RuntimeTransport` collaborator
directly rather than a chat-local ``ChatRuntime`` Protocol, and calls
``transport.perform_authed_post(build_request=..., log_label=parse_label, ...)``
on it.
The stub ``transport`` is a lightweight ``SimpleNamespace`` rather than a
``MagicMock(spec=RuntimeTransport)`` so the tests stay independent of the
class's exact member set — they only need the transport primitive the
function actually calls. The drain-fires-on-exception invariant is now
covered by
``tests/unit/test_drain_middleware.py::test_finish_fires_on_exception``.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import httpx
import pytest
from notebooklm._chat.transport import chat_aware_authed_post
from notebooklm._transport_errors import (
TransportAuthExpired,
TransportRateLimited,
TransportServerError,
)
from notebooklm.exceptions import ChatError, NetworkError
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
def _make_request() -> httpx.Request:
return httpx.Request("POST", "https://example.test/x")
def _make_status_error(code: int, *, retry_after: str | None = None) -> httpx.HTTPStatusError:
headers = {"retry-after": retry_after} if retry_after else {}
request = _make_request()
response = httpx.Response(code, request=request, headers=headers)
return httpx.HTTPStatusError(f"HTTP {code}", request=request, response=response)
def _make_stub_transport(
*,
transport_side_effect: Any = None,
transport_return_value: Any = None,
) -> SimpleNamespace:
"""Build a stub ``transport`` matching the slice of ``RuntimeTransport`` we exercise.
Pass ``transport_side_effect`` to make ``perform_authed_post`` raise
(exception instance) or invoke a callable; pass ``transport_return_value``
to make it return that response unchanged. Exactly one of the two
should be supplied per test — they are mutually exclusive.
PR 12.5 lifted ``_begin_transport_post`` / ``_finish_transport_post``
into DrainMiddleware, so the stub no longer needs to mock them —
``chat_aware_authed_post`` does not call them. Wave 8 of
session-decoupling switched the helper to take a
:class:`RuntimeTransport` directly, so the stub exposes the
transport's ``perform_authed_post`` method (the chat-side
``parse_label`` is forwarded to it as ``log_label``).
"""
return SimpleNamespace(
perform_authed_post=AsyncMock(
side_effect=transport_side_effect,
return_value=transport_return_value,
),
)
def _noop_build_request(_snapshot: Any) -> tuple[str, str, dict[str, str]]:
"""Build-request stub: the real transport invokes this, our stub does not."""
return "https://example.test/x", "payload", {}
# ---------------------------------------------------------------------------
# Happy path — bookkeeping
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_chat_aware_authed_post_returns_response_and_balances_bookkeeping():
"""Success path: response forwarded; begin/finish tokens balanced."""
expected_response = httpx.Response(200, request=_make_request())
transport = _make_stub_transport(transport_return_value=expected_response)
result = await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
assert result is expected_response
transport.perform_authed_post.assert_awaited_once_with(
build_request=_noop_build_request,
log_label="chat.ask",
read_timeout=None,
max_response_bytes=None,
disable_read_timeout_retries=False,
)
@pytest.mark.asyncio
async def test_chat_aware_authed_post_forwards_response_cap():
"""Chat-specific response cap is forwarded to the shared transport."""
expected_response = httpx.Response(200, request=_make_request())
transport = _make_stub_transport(transport_return_value=expected_response)
result = await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
max_response_bytes=123,
)
assert result is expected_response
transport.perform_authed_post.assert_awaited_once_with(
build_request=_noop_build_request,
log_label="chat.ask",
read_timeout=None,
max_response_bytes=123,
disable_read_timeout_retries=False,
)
# ---------------------------------------------------------------------------
# TransportAuthExpired → ChatError
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_transport_auth_expired_maps_to_chat_error():
original = _make_status_error(401)
transport_exc = TransportAuthExpired("auth refresh failed", original=original)
transport = _make_stub_transport(transport_side_effect=transport_exc)
with pytest.raises(ChatError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
assert "authentication expired" in str(excinfo.value).lower()
assert "chat.ask" in str(excinfo.value)
assert excinfo.value.__cause__ is transport_exc
# ---------------------------------------------------------------------------
# TransportRateLimited → ChatError
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_transport_rate_limited_with_retry_after_includes_retry_seconds():
original = _make_status_error(429, retry_after="42")
response = original.response
transport_exc = TransportRateLimited(
"rate-limited",
retry_after=42,
response=response,
original=original,
)
transport = _make_stub_transport(transport_side_effect=transport_exc)
with pytest.raises(ChatError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
message = str(excinfo.value)
assert "rate-limited" in message
assert "HTTP 429" in message
assert "Retry after 42 seconds" in message
assert excinfo.value.__cause__ is transport_exc
@pytest.mark.asyncio
async def test_transport_rate_limited_without_retry_after_omits_retry_clause():
original = _make_status_error(429)
response = original.response
transport_exc = TransportRateLimited(
"rate-limited",
retry_after=None,
response=response,
original=original,
)
transport = _make_stub_transport(transport_side_effect=transport_exc)
with pytest.raises(ChatError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
message = str(excinfo.value)
assert "rate-limited" in message
assert "HTTP 429" in message
assert "Retry after" not in message # No "Retry after N seconds" clause.
assert excinfo.value.__cause__ is transport_exc
# ---------------------------------------------------------------------------
# TransportServerError variants
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_transport_server_error_with_http_status_error_maps_to_chat_error():
original = _make_status_error(503)
transport_exc = TransportServerError(
"5xx after retries",
original=original,
response=original.response,
status_code=503,
)
transport = _make_stub_transport(transport_side_effect=transport_exc)
with pytest.raises(ChatError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
message = str(excinfo.value)
assert "HTTP 503" in message
assert "after retries" in message
assert excinfo.value.__cause__ is transport_exc
@pytest.mark.asyncio
async def test_transport_server_error_with_request_error_maps_to_network_error():
original = httpx.RequestError("connect failed", request=_make_request())
transport_exc = TransportServerError("network failure", original=original)
transport = _make_stub_transport(transport_side_effect=transport_exc)
with pytest.raises(NetworkError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
message = str(excinfo.value)
assert "network error after retries" in message
assert "timed out" not in message
assert excinfo.value.original_error is original
assert excinfo.value.__cause__ is transport_exc
@pytest.mark.asyncio
async def test_transport_server_error_with_read_timeout_includes_chat_stall_diagnostic(
monkeypatch: pytest.MonkeyPatch,
):
"""Regression: ``httpx.TimeoutException`` is a subclass of
``httpx.RequestError``; without the explicit timeout branch the message
would collapse to the generic "network error after retries" line."""
original = httpx.ReadTimeout("read timed out", request=_make_request())
transport_exc = TransportServerError("timeout", original=original)
transport = _make_stub_transport(transport_side_effect=transport_exc)
monkeypatch.setenv("NOTEBOOKLM_BL", "boq_labs-tailwind-frontend_test_p0")
with pytest.raises(NetworkError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
read_timeout=30.0,
)
message = str(excinfo.value)
assert "received no streamed chat bytes for 30s" in message
assert "slow-to-first-byte" in message
assert "chat-stream stall" in message
assert "shared notebooks" in message
assert "NOTEBOOKLM_BL='boq_labs-tailwind-frontend_test_p0'" in message
assert "--request-timeout 180" in message
assert "network error after retries" not in message
assert excinfo.value.original_error is original
assert excinfo.value.__cause__ is transport_exc
@pytest.mark.asyncio
async def test_transport_server_error_with_blank_read_timeout_message_has_no_stray_colon(
monkeypatch: pytest.MonkeyPatch,
):
original = httpx.ReadTimeout("", request=_make_request())
transport_exc = TransportServerError("timeout", original=original)
transport = _make_stub_transport(transport_side_effect=transport_exc)
monkeypatch.setenv("NOTEBOOKLM_BL", "boq_labs-tailwind-frontend_test_p0")
with pytest.raises(NetworkError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
read_timeout=3.0,
)
message = str(excinfo.value)
assert "received no streamed chat bytes for 3s. This points" in message
assert "for 3s: ." not in message
@pytest.mark.asyncio
async def test_transport_server_error_with_unexpected_original_type_raises_type_error():
"""Defensive: the transport layer should only wrap
``HTTPStatusError`` / ``RequestError`` into ``TransportServerError``.
Anything else surfaces as ``TypeError`` so an invariant drift is loud
rather than silently mis-mapped."""
class _UnexpectedException(Exception):
pass
original = _UnexpectedException("not http, not request")
transport_exc = TransportServerError("bogus original", original=original)
transport = _make_stub_transport(transport_side_effect=transport_exc)
with pytest.raises(TypeError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
message = str(excinfo.value)
assert "TransportServerError.original" in message
# The diagnostic must include both the actual type and the expected
# types so a future invariant drift produces an actionable error
# (per gemini-code-assist review on PR #832).
assert "Expected httpx.HTTPStatusError or httpx.RequestError" in message
assert excinfo.value.__cause__ is transport_exc
# ---------------------------------------------------------------------------
# Raw httpx.HTTPStatusError fall-through
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_raw_http_status_error_maps_to_chat_error():
"""Non-401 / non-429 / non-5xx status errors that fall through
``RuntimeTransport.perform_authed_post`` reach this layer as raw
``httpx.HTTPStatusError`` and get wrapped in a ``ChatError`` that
surfaces the status code."""
raw_exc = _make_status_error(404)
transport = _make_stub_transport(transport_side_effect=raw_exc)
with pytest.raises(ChatError) as excinfo:
await chat_aware_authed_post(
transport, # type: ignore[arg-type]
build_request=_noop_build_request,
parse_label="chat.ask",
)
message = str(excinfo.value)
assert "HTTP 404" in message
assert "chat.ask" in message
assert excinfo.value.__cause__ is raw_exc
# ---------------------------------------------------------------------------
# Finalization invariant (PR 12.5: moved into DrainMiddleware)
# ---------------------------------------------------------------------------
#
# The pre-PR-12.5 contract that ``chat_aware_authed_post`` ran
# ``_finish_transport_post`` in its own ``finally`` is no longer this
# function's responsibility — drain admission/finalization moved into
# ``DrainMiddleware`` at the outermost chain position. The exception-
# path finalization invariant is now pinned by
# ``tests/unit/test_drain_middleware.py::test_finish_fires_on_exception``,
# which exercises a real ``TransportDrainTracker`` end-to-end rather
# than mocking the bookkeeping.
#
# What remains here as a chat-specific invariant: the error-mapping
# still raises ``ChatError`` even when the underlying transport raises.
# The error-mapping tests above already cover that path
# (`test_transport_auth_expired_maps_to_chat_error` exercises the same
# transport exception used by the deleted finalization test).