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

318 lines
9.6 KiB
Python

"""Tests for per-request correlation IDs via contextvars.ContextVar."""
from __future__ import annotations
import asyncio
import io
import logging
import re
from contextvars import Token
import pytest
from notebooklm._logging import (
RedactingFilter,
RedactingFormatter,
get_request_id,
reset_request_id,
set_request_id,
)
from tests._helpers.client_factory import build_client_shell_for_tests
@pytest.fixture(autouse=True)
def clear_request_id():
"""Snapshot/restore the request id ContextVar around each test."""
from notebooklm._logging import _current_request_id
token = _current_request_id.set(_current_request_id.get())
try:
yield
finally:
_current_request_id.reset(token)
def _make_record(msg: str = "test") -> logging.LogRecord:
return logging.LogRecord(
name="notebooklm.test",
level=logging.WARNING,
pathname=__file__,
lineno=0,
msg=msg,
args=None,
exc_info=None,
)
# ---------------------------------------------------------------------------
# set/reset/get primitives
# ---------------------------------------------------------------------------
def test_set_request_id_returns_token_and_generated_id():
"""set_request_id() returns a Token; get_request_id() returns 8-hex."""
token = set_request_id()
try:
assert isinstance(token, Token)
rid = get_request_id()
assert rid is not None
assert re.fullmatch(r"[0-9a-f]{8}", rid)
finally:
reset_request_id(token)
def test_set_request_id_accepts_explicit_value():
"""Pass req_id=... to set a specific id."""
token = set_request_id("CUSTOM01")
try:
assert get_request_id() == "CUSTOM01"
finally:
reset_request_id(token)
def test_reset_restores_previous_value():
"""reset_request_id restores to PREVIOUS, not None."""
outer = set_request_id("OUTER001")
try:
inner = set_request_id("INNER001")
try:
assert get_request_id() == "INNER001"
finally:
reset_request_id(inner)
assert get_request_id() == "OUTER001"
finally:
reset_request_id(outer)
assert get_request_id() is None
# ---------------------------------------------------------------------------
# Filter integration
# ---------------------------------------------------------------------------
def test_filter_prefixes_after_scrub():
"""Filter prefixes [req=<id>] AFTER scrubbing. Token never appears in output."""
filt = RedactingFilter()
token = set_request_id("ABCD0123")
try:
rec = _make_record("scrubbable at=SECRET_TOK detail")
filt.filter(rec)
# Prefix outside the scrubbed body
assert rec.msg.startswith("[req=ABCD0123] ")
# Scrub still happened
assert "SECRET_TOK" not in rec.msg
assert "at=***" in rec.msg
finally:
reset_request_id(token)
def test_filter_no_prefix_when_id_unset():
"""With no id set, record.msg has no prefix."""
filt = RedactingFilter()
rec = _make_record("plain message")
filt.filter(rec)
assert not rec.msg.startswith("[req=")
assert rec.msg == "plain message"
def test_filter_no_double_prefix_through_two_filters():
"""Marker attribute prevents second filter pass from re-prefixing."""
filt1 = RedactingFilter()
filt2 = RedactingFilter()
token = set_request_id("XY00ZZ11")
try:
rec = _make_record("once")
filt1.filter(rec)
filt2.filter(rec) # second pass, simulating two handlers
# Only one [req=...] prefix
assert rec.msg.count("[req=XY00ZZ11]") == 1
finally:
reset_request_id(token)
def test_filter_includes_prefix_for_exc_info_records():
"""exc_info path also gets the prefix on the message line."""
filt = RedactingFilter()
token = set_request_id("EXC00001")
try:
try:
raise ValueError("oops at=LEAK")
except ValueError:
import sys
rec = logging.LogRecord(
name="notebooklm.test",
level=logging.ERROR,
pathname=__file__,
lineno=0,
msg="failure",
args=None,
exc_info=sys.exc_info(),
)
filt.filter(rec)
assert rec.msg.startswith("[req=EXC00001] ")
assert "LEAK" not in (rec.exc_text or "")
finally:
reset_request_id(token)
# ---------------------------------------------------------------------------
# asyncio Task isolation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_tasks_see_distinct_ids():
"""gather(task_a, task_b) — each sets its own id; emissions are tagged distinctly."""
captured: dict[str, str] = {}
async def worker(name: str, explicit_id: str) -> None:
token = set_request_id(explicit_id)
try:
await asyncio.sleep(0) # yield to interleave with sibling
captured[name] = get_request_id() or ""
finally:
reset_request_id(token)
await asyncio.gather(worker("a", "AAAA0000"), worker("b", "BBBB1111"))
assert captured["a"] == "AAAA0000"
assert captured["b"] == "BBBB1111"
@pytest.mark.asyncio
async def test_id_does_not_leak_after_reset():
"""After reset, subsequent emissions have no prefix."""
filt = RedactingFilter()
token = set_request_id("KEEP0001")
rec1 = _make_record("inside")
filt.filter(rec1)
assert "[req=KEEP0001]" in rec1.msg
reset_request_id(token)
rec2 = _make_record("outside")
filt.filter(rec2)
assert "[req=" not in rec2.msg
@pytest.mark.asyncio
async def test_correlation_threads_through_child_loggers():
"""Emissions from notebooklm._core and notebooklm._chat get same prefix
within a single task."""
filt = RedactingFilter()
token = set_request_id("CHILD001")
try:
rec_core = logging.LogRecord(
name="notebooklm._core",
level=logging.WARNING,
pathname=__file__,
lineno=0,
msg="core msg",
args=None,
exc_info=None,
)
rec_chat = logging.LogRecord(
name="notebooklm._chat",
level=logging.WARNING,
pathname=__file__,
lineno=0,
msg="chat msg",
args=None,
exc_info=None,
)
filt.filter(rec_core)
filt.filter(rec_chat)
assert rec_core.msg.startswith("[req=CHILD001] ")
assert rec_chat.msg.startswith("[req=CHILD001] ")
finally:
reset_request_id(token)
# ---------------------------------------------------------------------------
# Integration with full Formatter + Filter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_retry_inherits_parent_request_id():
"""Recursive executor.rpc_call(_is_retry=True) must NOT mint a fresh id — the
failure→refresh→retry sequence should appear under one prefix."""
from notebooklm.auth import AuthTokens
captured_ids: list[str | None] = []
async def fake_impl(
method,
params,
source_path,
allow_null,
is_retry,
*,
disable_internal_retries: bool = False,
operation_variant: str | None = None,
_refresh_budget=None,
_retry_deadline=None,
):
captured_ids.append(get_request_id())
# First call: raise to trigger retry path; second call: succeed.
if not is_retry:
# Mimic decode-time retry without leaving the executor.
return await executor.rpc_call(
method,
params,
source_path,
allow_null,
_is_retry=True,
)
return "ok"
# Real NotebookLMClient shell — the executor's open-client guard
# requires a truthy http_client, so we ``open()`` and let the lifecycle
# construct one against the default httpx transport. ``fake_impl`` is
# monkeypatched onto ``_execute_once`` so no actual HTTP call fires;
# the test exercises the request-id propagation through the executor
# wrapper purely in-process.
auth = AuthTokens(cookies={"SID": "test_sid"}, csrf_token="csrf", session_id="sid")
core = build_client_shell_for_tests(auth)
await core.__aenter__()
try:
executor = core._rpc_executor
executor._execute_once = fake_impl # type: ignore[method-assign]
result = await core._rpc_executor.rpc_call(method=object(), params=[]) # type: ignore[arg-type]
assert result == "ok"
assert len(captured_ids) == 2
assert captured_ids[0] == captured_ids[1]
assert captured_ids[0] is not None
finally:
await core.close()
def test_end_to_end_prefix_visible_in_rendered_output():
"""When wired to a real handler, the rendered output has [req=...] prefix."""
buf = io.StringIO()
handler = logging.StreamHandler(buf)
handler.setLevel(logging.NOTSET)
handler.setFormatter(RedactingFormatter(logging.Formatter("%(message)s")))
handler.addFilter(RedactingFilter())
logger = logging.getLogger("test_e2e_corr_unique")
logger.handlers.clear()
logger.filters.clear()
logger.addHandler(handler)
logger.setLevel(logging.WARNING)
logger.propagate = False
token = set_request_id("E2EE0001")
try:
logger.warning("hello with at=SECRET")
finally:
reset_request_id(token)
out = buf.getvalue()
assert "[req=E2EE0001]" in out
assert "SECRET" not in out
assert "at=***" in out
logger.handlers.clear()
logger.filters.clear()