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

354 lines
13 KiB
Python

"""Unit tests for :class:`notebooklm._middleware.tracing.TracingMiddleware`.
PR 12.3 of the Tier-12/13 greenfield migration lands ``TracingMiddleware``
as the innermost middleware in the chain (ADR-0009 §"Chain ordering"). The
middleware is a pure observer: it logs one "starting" record before
``next_call`` and one "completed"/"failed" record after, without
transforming the request or response.
These tests verify:
1. The middleware satisfies the :class:`Middleware` Protocol shape and
wires through the chain (smoke test via the shared
:func:`chain_calls_through_to_terminal` fixture).
2. Two log records are emitted around a successful call: one BEFORE
``next_call`` ("starting"), one AFTER ("completed" with
``status_code`` and ``duration_ms``).
3. ``rpc_method`` / ``log_label`` are propagated from
``request.context`` into the log record's ``extra`` mapping (so
structured-logging consumers see them as ``LogRecord`` attributes).
4. The response returned by the middleware is the exact instance
returned by ``next_call`` (identity-equal, no rewrap).
5. The request is unchanged after the middleware runs (frozen dataclass
enforces this at the type level; this test asserts it at runtime to
guard against a future maintainer relaxing the frozen invariant).
6. On exception from ``next_call``, the middleware emits a "failed"
record (with ``duration_ms`` and ``exception_type``) and re-raises
the original exception unchanged.
7. The middleware does NOT raise ``KeyError`` when handcrafted
middleware-chain fixtures omit ``rpc_method`` from ``request.context``;
production transport calls always include it.
The tests use stdlib :func:`caplog` to capture log records — no
production logger reconfiguration leaks across tests. The chain is
driven directly via :func:`build_chain` rather than going through
``Session`` so the unit boundary is the middleware itself, not the
full client.
"""
from __future__ import annotations
import asyncio
import logging
import httpx
import pytest
from notebooklm._middleware.core import (
Middleware,
RpcRequest,
RpcResponse,
build_chain,
)
from notebooklm._middleware.tracing import TracingMiddleware
# The ``tests/`` package chain is complete; ``tests._fixtures.chain`` is the
# fully-qualified import path documented in ``tests/_fixtures/__init__.py``.
from tests._fixtures.chain import (
FakeChainTerminal,
chain_calls_through_to_terminal,
make_request,
)
_TRACE_LOGGER = "notebooklm.middleware.tracing"
# ---------------------------------------------------------------------------
# Protocol / wire-up.
# ---------------------------------------------------------------------------
def test_tracing_middleware_satisfies_protocol() -> None:
"""``TracingMiddleware`` is assignable into ``Middleware`` (Protocol check).
Static check at runtime: assignment to a ``Middleware``-typed variable
is the mypy-visible equivalent of "satisfies the Protocol." If a
future change adds a positional arg to ``__call__``, this assignment
fails type-check; the runtime assertion guards against the runtime
invariant too.
"""
middleware: Middleware = TracingMiddleware()
assert middleware is not None
def test_tracing_middleware_calls_through_to_transport() -> None:
"""Chain of ``[TracingMiddleware()]`` reaches the terminal exactly once.
Uses the shared :func:`chain_calls_through_to_terminal` fixture from
``tests/_fixtures/chain.py`` — the canonical wire-up smoke test for
every middleware PR per ADR-0009 §"Per-position rationale" and master
plan line 105.
"""
terminal = FakeChainTerminal()
assert chain_calls_through_to_terminal(terminal, [TracingMiddleware()])
assert terminal.call_count == 1
# ---------------------------------------------------------------------------
# Logging behaviour.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_emits_starting_and_completed_records_on_success(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Two records around a successful call: one "starting", one "completed".
Verifies the per-attempt visibility requirement from ADR-0009 §"Chain
ordering" (Tracing innermost — "logs every actual HTTP attempt").
"""
expected_response = httpx.Response(status_code=200, content=b"ok")
async def terminal(_request: RpcRequest) -> RpcResponse:
return RpcResponse(response=expected_response, context={})
chain = build_chain([TracingMiddleware()], terminal)
with caplog.at_level(logging.DEBUG, logger=_TRACE_LOGGER):
result = await chain(
make_request(
context={"rpc_method": "LIST_NOTEBOOKS", "log_label": "RPC LIST_NOTEBOOKS"}
)
)
assert result.response is expected_response
records = [r for r in caplog.records if r.name == _TRACE_LOGGER]
assert len(records) == 2
starting, completed = records
assert starting.getMessage() == "rpc starting: RPC LIST_NOTEBOOKS"
assert starting.rpc_method == "LIST_NOTEBOOKS"
assert starting.log_label == "RPC LIST_NOTEBOOKS"
# ``starting`` is emitted BEFORE the call returns, so no status_code /
# duration_ms yet (LogRecord lacks those attributes — accessing them
# would raise ``AttributeError`` rather than return ``None``).
assert not hasattr(starting, "status_code")
assert not hasattr(starting, "duration_ms")
assert completed.getMessage() == "rpc completed: RPC LIST_NOTEBOOKS -> 200"
assert completed.rpc_method == "LIST_NOTEBOOKS"
assert completed.log_label == "RPC LIST_NOTEBOOKS"
assert completed.status_code == 200
assert isinstance(completed.duration_ms, float)
assert completed.duration_ms >= 0.0
@pytest.mark.asyncio
async def test_starting_record_is_emitted_before_next_call(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The "starting" record is emitted *before* the terminal runs.
Validated by recording the log count inside the terminal: at that
point exactly one record has been emitted (the "starting" one); the
"completed" record cannot land until ``next_call`` returns.
"""
log_count_during_terminal: list[int] = []
async def terminal(_request: RpcRequest) -> RpcResponse:
log_count_during_terminal.append(sum(1 for r in caplog.records if r.name == _TRACE_LOGGER))
return RpcResponse(response=httpx.Response(status_code=200, content=b""), context={})
chain = build_chain([TracingMiddleware()], terminal)
with caplog.at_level(logging.DEBUG, logger=_TRACE_LOGGER):
await chain(make_request(context={"log_label": "ordering-check"}))
assert log_count_during_terminal == [1]
@pytest.mark.asyncio
async def test_rpc_method_absent_does_not_raise(
caplog: pytest.LogCaptureFixture,
) -> None:
"""``rpc_method`` missing from context is fine — middleware logs ``None``.
Handcrafted middleware-chain fixtures may omit ``rpc_method``; production
transport calls always include it. The middleware must handle this
gracefully (``.get()`` returns ``None``, no ``KeyError``).
"""
async def terminal(_request: RpcRequest) -> RpcResponse:
return RpcResponse(response=httpx.Response(status_code=204, content=b""), context={})
chain = build_chain([TracingMiddleware()], terminal)
with caplog.at_level(logging.DEBUG, logger=_TRACE_LOGGER):
# Context has ``log_label`` but no ``rpc_method``.
await chain(make_request(context={"log_label": "no-rpc-method"}))
records = [r for r in caplog.records if r.name == _TRACE_LOGGER]
assert len(records) == 2
assert records[0].rpc_method is None
assert records[1].rpc_method is None
assert records[1].log_label == "no-rpc-method"
assert records[1].status_code == 204
@pytest.mark.asyncio
async def test_empty_context_does_not_raise(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Even with a fully-empty ``context`` dict, both records emit with ``None`` fields.
Edge case: ``make_request()`` defaults ``context`` to ``{}``. The
middleware should not require any specific key.
"""
async def terminal(_request: RpcRequest) -> RpcResponse:
return RpcResponse(response=httpx.Response(status_code=200, content=b""), context={})
chain = build_chain([TracingMiddleware()], terminal)
with caplog.at_level(logging.DEBUG, logger=_TRACE_LOGGER):
await chain(make_request())
records = [r for r in caplog.records if r.name == _TRACE_LOGGER]
assert len(records) == 2
for record in records:
assert record.rpc_method is None
assert record.log_label is None
# ---------------------------------------------------------------------------
# Pass-through invariants.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_response_passthrough_identity() -> None:
"""The middleware returns the exact ``RpcResponse`` produced by ``next_call``.
Pure observer contract: no rewrap, no replace. Identity equality
(``is``) catches a future maintainer who accidentally wraps the
response in a new dataclass; value equality would not.
"""
sentinel_response = RpcResponse(
response=httpx.Response(status_code=201, content=b"made"),
context={"trace_id": "abc-123"},
)
async def terminal(_request: RpcRequest) -> RpcResponse:
return sentinel_response
chain = build_chain([TracingMiddleware()], terminal)
result = await chain(make_request())
assert result is sentinel_response
@pytest.mark.asyncio
async def test_request_is_not_mutated() -> None:
"""The middleware does not mutate ``request`` — frozen dataclass + identity.
The frozen ``RpcRequest`` dataclass enforces immutability at the
type level (assigning to a field raises ``FrozenInstanceError``).
This test asserts at runtime that the terminal receives the *same*
``RpcRequest`` instance the middleware was given — no
``dataclasses.replace`` happened in between.
"""
received_requests: list[RpcRequest] = []
async def terminal(request: RpcRequest) -> RpcResponse:
received_requests.append(request)
return RpcResponse(response=httpx.Response(status_code=200, content=b""), context={})
chain = build_chain([TracingMiddleware()], terminal)
sent = make_request(context={"log_label": "no-mutation"})
await chain(sent)
assert len(received_requests) == 1
assert received_requests[0] is sent
# ---------------------------------------------------------------------------
# Failure path.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_failure_emits_failed_record_and_reraises(
caplog: pytest.LogCaptureFixture,
) -> None:
"""If ``next_call`` raises, emit a "failed" record and re-raise unchanged.
Verifies the never-swallow contract: the propagated exception is the
exact instance raised by the terminal (``is``-equal), and a
``WARNING``-level record with ``exception_type`` and ``duration_ms``
is emitted before the re-raise.
"""
boom = RuntimeError("transport-failed")
async def terminal(_request: RpcRequest) -> RpcResponse:
raise boom
chain = build_chain([TracingMiddleware()], terminal)
with (
caplog.at_level(logging.DEBUG, logger=_TRACE_LOGGER),
pytest.raises(RuntimeError) as exc_info,
):
await chain(make_request(context={"log_label": "boom-label"}))
assert exc_info.value is boom
records = [r for r in caplog.records if r.name == _TRACE_LOGGER]
# "starting" + "failed".
assert len(records) == 2
failed = records[1]
assert failed.levelno == logging.WARNING
assert "rpc failed: boom-label" in failed.getMessage()
assert failed.log_label == "boom-label"
assert failed.exception_type == "RuntimeError"
assert isinstance(failed.duration_ms, float)
assert failed.duration_ms >= 0.0
@pytest.mark.asyncio
async def test_cancelled_error_bypasses_failed_record(
caplog: pytest.LogCaptureFixture,
) -> None:
"""``asyncio.CancelledError`` propagates without emitting a "failed" record.
``CancelledError`` is a :class:`BaseException` subclass (Python 3.8+), and
the ``except Exception`` clause in :class:`TracingMiddleware` is
deliberately narrow: cooperative-cancellation signals
(``CancelledError`` / ``KeyboardInterrupt`` / ``SystemExit``) are caller-
initiated unwinds, not RPC failures, so they bypass the failure-trace
path entirely. Only the "starting" record lands.
Pinning this in a test guards against a future maintainer widening the
``except`` to ``BaseException`` (or adding a bare ``except``), which
would silently turn benign cancellations into noisy "failed" warnings
and inflate the duration_ms latency histogram.
"""
async def terminal(_request: RpcRequest) -> RpcResponse:
raise asyncio.CancelledError()
chain = build_chain([TracingMiddleware()], terminal)
with (
caplog.at_level(logging.DEBUG, logger=_TRACE_LOGGER),
pytest.raises(asyncio.CancelledError),
):
await chain(make_request(context={"log_label": "cancel-test"}))
records = [r for r in caplog.records if r.name == _TRACE_LOGGER]
assert len(records) == 1
assert "rpc starting" in records[0].getMessage()
assert records[0].log_label == "cancel-test"