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
600 lines
26 KiB
Python
600 lines
26 KiB
Python
"""Integration tests for client initialization and core functionality."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from notebooklm import AuthTokens, NotebookLMClient
|
|
from notebooklm._runtime.helpers import is_auth_error
|
|
from notebooklm.rpc import (
|
|
AuthError,
|
|
ClientError,
|
|
NetworkError,
|
|
RateLimitError,
|
|
RPCError,
|
|
RPCMethod,
|
|
RPCTimeoutError,
|
|
ServerError,
|
|
)
|
|
from tests._helpers.client_factory import build_client_shell_for_tests
|
|
from tests.integration.conftest import install_post_as_stream
|
|
|
|
# httpx-mock + MagicMock based core-layer tests; no real HTTP, no
|
|
# cassette. Opt out of the tier-enforcement hook in tests/integration/conftest.py.
|
|
pytestmark = pytest.mark.allow_no_vcr
|
|
|
|
|
|
def _install_error_post(core: NotebookLMClient, error: Exception) -> AsyncMock:
|
|
mock_post = AsyncMock(side_effect=error)
|
|
install_post_as_stream(None, core._collaborators.kernel.get_http_client(), mock_post)
|
|
return mock_post
|
|
|
|
|
|
class TestClientInitialization:
|
|
@pytest.mark.asyncio
|
|
async def test_client_initialization(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
assert client._auth == auth_tokens
|
|
assert client._collaborators.kernel.http_client is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_context_manager_closes(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
assert client._collaborators.kernel.http_client is not None # client is open
|
|
assert client._collaborators.kernel.http_client is None # closed after exit
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_does_not_sync_in_memory_auth_to_default_storage(self):
|
|
auth = AuthTokens(cookies={"SID": "scratch"}, csrf_token="csrf", session_id="session")
|
|
# Inject the cookie-saver seam directly (Phase 2 PR 4 — replaces the
|
|
# legacy ``_core.save_cookies_to_storage`` string-target monkeypatch
|
|
# with a constructor-injection seam wired through
|
|
# ``ClientLifecycle._cookie_saver``).
|
|
mock_save = MagicMock(return_value=False)
|
|
core = build_client_shell_for_tests(auth, cookie_saver=mock_save)
|
|
await core.__aenter__()
|
|
|
|
await core.close()
|
|
|
|
mock_save.assert_not_called()
|
|
assert core._collaborators.kernel.http_client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_closes_http_client_when_cookie_sync_fails(self, auth_tokens, tmp_path):
|
|
auth_tokens.storage_path = tmp_path / "storage_state.json"
|
|
# Inject a cookie-saver that raises so the test exercises the
|
|
# close()-handles-saver-failure path without monkeypatching the
|
|
# legacy ``_core.save_cookies_to_storage`` seam.
|
|
boom_save = MagicMock(side_effect=RuntimeError("boom"))
|
|
core = build_client_shell_for_tests(auth_tokens, cookie_saver=boom_save)
|
|
await core.__aenter__()
|
|
|
|
await core.close()
|
|
|
|
# Assert the injected saver was actually invoked — otherwise the
|
|
# test could pass via an early exit that never reaches the saver,
|
|
# silently weakening the regression guard.
|
|
boom_save.assert_called_once()
|
|
assert core._collaborators.kernel.http_client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_raises_if_not_initialized(self, auth_tokens):
|
|
client = NotebookLMClient(auth_tokens)
|
|
with pytest.raises(RuntimeError, match="not initialized"):
|
|
await client.notebooks.list()
|
|
|
|
|
|
class TestIsAuthError:
|
|
"""Tests for the is_auth_error() helper function."""
|
|
|
|
def test_returns_true_for_auth_error(self):
|
|
assert is_auth_error(AuthError("invalid credentials")) is True
|
|
|
|
def test_returns_false_for_network_error(self):
|
|
assert is_auth_error(NetworkError("network down")) is False
|
|
|
|
def test_returns_false_for_rate_limit_error(self):
|
|
assert is_auth_error(RateLimitError("rate limited")) is False
|
|
|
|
def test_returns_false_for_server_error(self):
|
|
assert is_auth_error(ServerError("500 error")) is False
|
|
|
|
def test_returns_false_for_client_error(self):
|
|
# ClientError subclass is explicitly excluded (already mapped, no retry).
|
|
# Raw httpx 400 is treated as an auth error; see
|
|
# test_returns_true_for_400_http_status_error.
|
|
assert is_auth_error(ClientError("400 bad request")) is False
|
|
|
|
def test_returns_true_for_400_http_status_error(self):
|
|
# NotebookLM returns 400 (not 401/403) when the CSRF token in the at=
|
|
# body param is stale. is_auth_error must include 400 so the layer-1
|
|
# refresh_auth retry path fires for stale CSRF.
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 400
|
|
error = httpx.HTTPStatusError("400", request=MagicMock(), response=mock_response)
|
|
assert is_auth_error(error) is True
|
|
|
|
def test_returns_false_for_rpc_timeout_error(self):
|
|
assert is_auth_error(RPCTimeoutError("timed out")) is False
|
|
|
|
def test_returns_true_for_401_http_status_error(self):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 401
|
|
error = httpx.HTTPStatusError("401", request=MagicMock(), response=mock_response)
|
|
assert is_auth_error(error) is True
|
|
|
|
def test_returns_true_for_403_http_status_error(self):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 403
|
|
error = httpx.HTTPStatusError("403", request=MagicMock(), response=mock_response)
|
|
assert is_auth_error(error) is True
|
|
|
|
def test_returns_false_for_500_http_status_error(self):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
error = httpx.HTTPStatusError("500", request=MagicMock(), response=mock_response)
|
|
assert is_auth_error(error) is False
|
|
|
|
@pytest.mark.parametrize("rpc_code", [401, 403, 16, "UNAUTHENTICATED"])
|
|
def test_returns_true_for_rpc_error_with_auth_code(self, rpc_code):
|
|
assert is_auth_error(RPCError("authentication expired", rpc_code=rpc_code)) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
"Unauthorized access to this notebook",
|
|
"Session expired while rendering an unrelated artifact",
|
|
"Authentication summary could not be generated",
|
|
],
|
|
)
|
|
def test_returns_false_for_rpc_error_with_auth_words_but_no_auth_signal(self, message):
|
|
assert is_auth_error(RPCError(message)) is False
|
|
|
|
def test_returns_false_for_rpc_error_with_generic_message(self):
|
|
assert is_auth_error(RPCError("some generic error")) is False
|
|
|
|
def test_returns_false_for_plain_exception(self):
|
|
assert is_auth_error(ValueError("not an rpc error")) is False
|
|
|
|
|
|
class TestRPCCallHTTPErrors:
|
|
"""Tests for HTTP error handling in rpc_call()."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_429_with_retry_after_header(self, auth_tokens):
|
|
# Pin ``rate_limit_max_retries=0`` to exercise the raise-immediately
|
|
# path. The rate-limit fix raised the default to 3 — the post-retries raise is
|
|
# covered by ``tests/integration/concurrency/test_rate_limit_default.py``;
|
|
# this test documents the explicit-disable contract.
|
|
async with NotebookLMClient(auth_tokens, rate_limit_max_retries=0) as client:
|
|
core = client
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 429
|
|
mock_response.headers = {"retry-after": "60"}
|
|
mock_response.reason_phrase = "Too Many Requests"
|
|
error = httpx.HTTPStatusError("429", request=MagicMock(), response=mock_response)
|
|
|
|
_install_error_post(core, error)
|
|
with pytest.raises(RateLimitError) as exc_info:
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
assert exc_info.value.retry_after == 60
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_429_without_retry_after_header(self, auth_tokens):
|
|
# See ``test_rate_limit_429_with_retry_after_header`` for why this
|
|
# pins ``rate_limit_max_retries=0``.
|
|
async with NotebookLMClient(auth_tokens, rate_limit_max_retries=0) as client:
|
|
core = client
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 429
|
|
mock_response.headers = {}
|
|
mock_response.reason_phrase = "Too Many Requests"
|
|
error = httpx.HTTPStatusError("429", request=MagicMock(), response=mock_response)
|
|
|
|
_install_error_post(core, error)
|
|
with pytest.raises(RateLimitError) as exc_info:
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
assert exc_info.value.retry_after is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_429_with_invalid_retry_after_header(self, auth_tokens):
|
|
# See ``test_rate_limit_429_with_retry_after_header`` for why this
|
|
# pins ``rate_limit_max_retries=0``.
|
|
async with NotebookLMClient(auth_tokens, rate_limit_max_retries=0) as client:
|
|
core = client
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 429
|
|
mock_response.headers = {"retry-after": "not-a-number"}
|
|
mock_response.reason_phrase = "Too Many Requests"
|
|
error = httpx.HTTPStatusError("429", request=MagicMock(), response=mock_response)
|
|
|
|
_install_error_post(core, error)
|
|
with pytest.raises(RateLimitError) as exc_info:
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
assert exc_info.value.retry_after is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_error_400(self, auth_tokens):
|
|
# With the stale-CSRF fix, HTTP 400 is treated as an auth error and
|
|
# routed through _try_refresh_and_retry first. To exercise the raw
|
|
# 400 → ClientError mapping (back-compat for callers that don't opt
|
|
# in to auto-refresh), clear the refresh callback so is_auth_error's
|
|
# gate in rpc_call short-circuits and the status mapping runs.
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
core._collaborators.auth_coord._refresh_callback = None
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 400
|
|
mock_response.reason_phrase = "Bad Request"
|
|
error = httpx.HTTPStatusError("400", request=MagicMock(), response=mock_response)
|
|
|
|
_install_error_post(core, error)
|
|
with pytest.raises(ClientError):
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_error_500(self, auth_tokens):
|
|
# Pin ``server_error_max_retries=0`` to exercise the raise-immediately
|
|
# mapping path. Retry/backoff behavior is covered in core transport tests.
|
|
async with NotebookLMClient(auth_tokens, server_error_max_retries=0) as client:
|
|
core = client
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
mock_response.reason_phrase = "Internal Server Error"
|
|
error = httpx.HTTPStatusError("500", request=MagicMock(), response=mock_response)
|
|
|
|
_install_error_post(core, error)
|
|
with pytest.raises(ServerError):
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_timeout_raises_network_error(self, auth_tokens):
|
|
# Network errors flow through the same retry loop as 5xx responses;
|
|
# pin to 0 so these mapping tests don't pay backoff sleeps.
|
|
async with NotebookLMClient(auth_tokens, server_error_max_retries=0) as client:
|
|
core = client
|
|
_install_error_post(core, httpx.ConnectTimeout("connect timeout"))
|
|
with pytest.raises(NetworkError):
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_timeout_raises_rpc_timeout_error(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens, server_error_max_retries=0) as client:
|
|
core = client
|
|
_install_error_post(core, httpx.ReadTimeout("read timeout"))
|
|
with pytest.raises(RPCTimeoutError):
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_error_raises_network_error(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens, server_error_max_retries=0) as client:
|
|
core = client
|
|
_install_error_post(core, httpx.ConnectError("connection refused"))
|
|
with pytest.raises(NetworkError):
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generic_request_error_raises_network_error(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens, server_error_max_retries=0) as client:
|
|
core = client
|
|
_install_error_post(core, httpx.RequestError("something went wrong"))
|
|
with pytest.raises(NetworkError):
|
|
await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
|
|
|
|
class TestRPCCallAuthRetry:
|
|
"""Tests for auth retry path after decode_response raises RPCError."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_retry_on_decode_rpc_error(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
refresh_callback = AsyncMock()
|
|
core._collaborators.auth_coord._refresh_callback = refresh_callback
|
|
import asyncio
|
|
|
|
# Pre-allocate the lock so the first refresh attempt doesn't
|
|
# try to construct one (the coordinator's lazy-init runs at
|
|
# the first ``await_refresh`` call site).
|
|
core._collaborators.auth_coord._refresh_lock = asyncio.Lock()
|
|
|
|
success_response = MagicMock()
|
|
success_response.status_code = 200
|
|
success_response.text = "some_valid_response"
|
|
|
|
mock_post = AsyncMock(return_value=success_response)
|
|
install_post_as_stream(None, core._collaborators.kernel.get_http_client(), mock_post)
|
|
|
|
# Override the runtime decode-response seam before the RPC fires.
|
|
decode_responses = iter(
|
|
[
|
|
RPCError("authentication expired", rpc_code=401),
|
|
["result_data"],
|
|
]
|
|
)
|
|
|
|
def fake_decode(*_a, **_kw):
|
|
value = next(decode_responses)
|
|
if isinstance(value, BaseException):
|
|
raise value
|
|
return value
|
|
|
|
core._seams.decode_response = fake_decode
|
|
|
|
result = await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
|
|
|
assert result == ["result_data"]
|
|
refresh_callback.assert_called_once()
|
|
|
|
|
|
class TestGetHttpClient:
|
|
"""Tests for get_http_client() RuntimeError when not initialized.
|
|
|
|
Wave 11b of session-decoupling: the ``NotebookLMClient.get_http_client`` forward
|
|
was deleted; the canonical home is ``Kernel.get_http_client`` (reached
|
|
via ``core._collaborators.kernel`` / ``client._collaborators.kernel``).
|
|
"""
|
|
|
|
def test_get_http_client_raises_when_not_initialized(self, auth_tokens):
|
|
core = build_client_shell_for_tests(auth_tokens)
|
|
with pytest.raises(RuntimeError, match="not initialized"):
|
|
core._collaborators.kernel.get_http_client()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_http_client_returns_client_when_initialized(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
http_client = client._collaborators.kernel.get_http_client()
|
|
assert isinstance(http_client, httpx.AsyncClient)
|
|
|
|
|
|
class TestGetSourceIds:
|
|
"""Tests for NotebooksAPI.get_source_ids() extracting source IDs from notebook data."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_source_ids_from_nested_data(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
notebooks = client.notebooks
|
|
|
|
mock_notebook_data = [
|
|
[
|
|
"notebook_title",
|
|
[
|
|
[["src_id_1", "extra"]],
|
|
[["src_id_2", "extra"]],
|
|
],
|
|
]
|
|
]
|
|
|
|
with patch.object(
|
|
core._rpc_executor,
|
|
"rpc_call",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_notebook_data,
|
|
):
|
|
ids = await notebooks.get_source_ids("nb_123")
|
|
|
|
assert ids == ["src_id_1", "src_id_2"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_empty_list_when_data_is_none(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
notebooks = client.notebooks
|
|
|
|
with patch.object(
|
|
core._rpc_executor, "rpc_call", new_callable=AsyncMock, return_value=None
|
|
):
|
|
ids = await notebooks.get_source_ids("nb_123")
|
|
|
|
assert ids == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_empty_list_when_data_is_empty_list(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
notebooks = client.notebooks
|
|
|
|
with patch.object(
|
|
core._rpc_executor, "rpc_call", new_callable=AsyncMock, return_value=[]
|
|
):
|
|
ids = await notebooks.get_source_ids("nb_123")
|
|
|
|
assert ids == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_empty_list_when_sources_list_is_empty(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
notebooks = client.notebooks
|
|
|
|
# Notebook with no sources
|
|
mock_notebook_data = [["notebook_title", []]]
|
|
|
|
with patch.object(
|
|
core._rpc_executor,
|
|
"rpc_call",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_notebook_data,
|
|
):
|
|
ids = await notebooks.get_source_ids("nb_123")
|
|
|
|
assert ids == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_empty_list_when_data_is_not_list(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
notebooks = client.notebooks
|
|
|
|
with patch.object(
|
|
core._rpc_executor,
|
|
"rpc_call",
|
|
new_callable=AsyncMock,
|
|
return_value="unexpected_string",
|
|
):
|
|
ids = await notebooks.get_source_ids("nb_123")
|
|
|
|
assert ids == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_empty_list_when_notebook_info_missing_sources(self, auth_tokens):
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
notebooks = client.notebooks
|
|
|
|
# notebook_data[0] exists but notebook_info[1] is missing
|
|
mock_notebook_data = [["notebook_title_only"]]
|
|
|
|
with patch.object(
|
|
core._rpc_executor,
|
|
"rpc_call",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_notebook_data,
|
|
):
|
|
ids = await notebooks.get_source_ids("nb_123")
|
|
|
|
assert ids == []
|
|
|
|
|
|
class TestCrossDomainCookiePreservation:
|
|
"""Tests for cookie preservation during cross-domain redirects."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cookies_preserved_on_cross_domain_redirect(self, auth_tokens):
|
|
"""Verify cookies persist when redirecting from notebooklm to accounts.google.com."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
http_client = core._collaborators.kernel.get_http_client()
|
|
|
|
# Set initial sentinel cookie in the jar
|
|
http_client.cookies.set("REDIRECT_SENTINEL", "survives_refresh", domain=".google.com")
|
|
|
|
# Simulate what happens during a redirect: update_auth_headers merges new cookies
|
|
# without wiping existing ones (like refreshed SID from accounts.google.com).
|
|
# Wave 3 of plan ``host-protocol-removal`` deleted the
|
|
# NotebookLMClient-level ``update_auth_headers`` forward; call the
|
|
# canonical coordinator method directly with explicit kwargs.
|
|
core._collaborators.auth_coord.update_auth_headers(
|
|
auth=core._auth, kernel=core._collaborators.kernel
|
|
)
|
|
|
|
# Verify original cookies are still present (not wiped)
|
|
# httpx.Cookies.get() returns None if cookie not found
|
|
assert (
|
|
http_client.cookies.get("REDIRECT_SENTINEL", domain=".google.com")
|
|
== "survives_refresh"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_auth_headers_merges_not_replaces(self, auth_tokens):
|
|
"""Verify update_auth_headers merges new cookies, preserving live redirect cookies."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
http_client = core._collaborators.kernel.get_http_client()
|
|
|
|
# Simulate a live cookie received from accounts.google.com redirect
|
|
http_client.cookies.set(
|
|
"__Secure-1PSIDRTS", "redirect_refreshed_value", domain=".google.com"
|
|
)
|
|
|
|
# Now update auth headers (simulating a token refresh).
|
|
# Wave 3 of plan ``host-protocol-removal`` deleted the
|
|
# NotebookLMClient-level ``update_auth_headers`` forward; call the
|
|
# canonical coordinator method directly with explicit kwargs.
|
|
core._collaborators.auth_coord.update_auth_headers(
|
|
auth=core._auth, kernel=core._collaborators.kernel
|
|
)
|
|
|
|
# The EXACT value should still be there (merged, not replaced)
|
|
assert (
|
|
http_client.cookies.get("__Secure-1PSIDRTS", domain=".google.com")
|
|
== "redirect_refreshed_value"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_googleusercontent_cookies_not_reassigned(self, auth_tokens):
|
|
"""Cookies for .googleusercontent.com must not be forced to .google.com."""
|
|
# Set a cookie with googleusercontent domain via the cookie_jar
|
|
auth_tokens.cookie_jar = httpx.Cookies()
|
|
auth_tokens.cookie_jar.set("download_token", "abc123", domain=".googleusercontent.com")
|
|
auth_tokens.cookie_jar.set("SID", "test_sid", domain=".google.com")
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
http = core._collaborators.kernel.get_http_client()
|
|
|
|
# The .googleusercontent.com cookie must remain on its original domain
|
|
assert http.cookies.get("download_token", domain=".googleusercontent.com") == "abc123"
|
|
# It must NOT appear on .google.com
|
|
assert http.cookies.get("download_token", domain=".google.com") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_auth_headers_preserves_redirect_cookies(self, auth_tokens):
|
|
"""update_auth_headers must merge, not replace, preserving redirect cookies."""
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
core = client
|
|
http = core._collaborators.kernel.get_http_client()
|
|
|
|
# Simulate Google setting a cookie during a redirect
|
|
http.cookies.set("__Secure-1PSIDCC", "from_redirect", domain=".google.com")
|
|
|
|
# Now update auth headers.
|
|
# Wave 3 of plan ``host-protocol-removal`` deleted the
|
|
# NotebookLMClient-level ``update_auth_headers`` forward; call the
|
|
# canonical coordinator method directly with explicit kwargs.
|
|
core._collaborators.auth_coord.update_auth_headers(
|
|
auth=core._auth, kernel=core._collaborators.kernel
|
|
)
|
|
|
|
# The redirect cookie must survive
|
|
assert http.cookies.get("__Secure-1PSIDCC", domain=".google.com") == "from_redirect"
|
|
|
|
|
|
class TestBuildUrlHL:
|
|
"""``RpcExecutor.build_url()`` must thread NOTEBOOKLM_HL into the
|
|
batchexecute URL.
|
|
|
|
This is the load-bearing site for setting the interface language on
|
|
every RPC call. The NotebookLMClient-level ``_build_url`` thin wrapper was
|
|
inlined in PR #4b — callers reach the canonical method through
|
|
``core._rpc_executor.build_url(...)``.
|
|
|
|
``RpcExecutor.build_url`` requires an ``AuthSnapshot`` (consumes
|
|
``session_id`` / ``authuser`` / ``account_email`` from it rather
|
|
than reading ``self.auth`` live). Tests construct a snapshot inline
|
|
from the fixture's ``AuthTokens`` so the URL-construction logic is
|
|
exercised without spinning up the shared authed transport path.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _snapshot_for(core):
|
|
from notebooklm._request_types import AuthSnapshot
|
|
|
|
return AuthSnapshot(
|
|
csrf_token=core._auth.csrf_token,
|
|
session_id=core._auth.session_id,
|
|
authuser=core._auth.authuser,
|
|
account_email=core._auth.account_email,
|
|
)
|
|
|
|
def test_build_url_defaults_hl_to_en(self, auth_tokens, monkeypatch):
|
|
monkeypatch.delenv("NOTEBOOKLM_HL", raising=False)
|
|
core = build_client_shell_for_tests(auth_tokens)
|
|
url = core._rpc_executor.build_url(RPCMethod.LIST_NOTEBOOKS, self._snapshot_for(core))
|
|
assert "hl=en" in url
|
|
|
|
def test_build_url_includes_hl_from_env(self, auth_tokens, monkeypatch):
|
|
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
|
|
core = build_client_shell_for_tests(auth_tokens)
|
|
url = core._rpc_executor.build_url(RPCMethod.LIST_NOTEBOOKS, self._snapshot_for(core))
|
|
assert "hl=ja" in url
|
|
|
|
def test_build_url_empty_env_falls_back_to_en(self, auth_tokens, monkeypatch):
|
|
monkeypatch.setenv("NOTEBOOKLM_HL", "")
|
|
core = build_client_shell_for_tests(auth_tokens)
|
|
url = core._rpc_executor.build_url(RPCMethod.LIST_NOTEBOOKS, self._snapshot_for(core))
|
|
assert "hl=en" in url
|