Files
teng-lin--notebooklm-py/tests/server/test_integration_real_client.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

238 lines
10 KiB
Python

"""Integration tests driving the REST app through a **real** ``NotebookLMClient``
backed by **existing** VCR cassettes.
The rest of ``tests/server/`` uses an in-memory :class:`FakeClient`, which is fast
and good for adapter wiring but cannot catch divergence between the fake and the
real client runtime — real ``rpc/`` decode, real ``_app.serialize`` shapes, and
real ``_app.errors.classify`` → HTTP status projection.
These tests close that gap by binding ``create_app`` to a real client and
**replaying recorded cassettes** from ``tests/cassettes`` (the same recordings the
``tests/integration`` / CLI-VCR suites use). No new traffic is recorded. VCR
matches on ``rpcids`` + ``f.req`` body *shape* (not leaf id values), so a
placeholder notebook id drives any same-shape recording.
Surface sampled through the real stack (broad ``/v1`` coverage, not every route):
* notebooks — list, get, create, delete
* sources — list, add url, add text, add file, delete
* chat — ask
* artifacts — list, generate, poll, download
* errors — a real 429 → RATE_LIMITED → 429 and a real 5xx → 502 projection
The artifact generate/poll/download and file-upload legs replay server-shaped
cassettes recorded by ``tests/scripts/record_server_cassettes.py`` (the server
emits a different ``f.req`` than the CLI, so the CLI cassettes don't match).
The gRPC-status-5 → 404 contract is covered by composition:
``tests/unit/test_decoder.py`` (real status-5 frame → ``ClientError(rpc_code=5)``)
+ ``tests/server/test_errors.py`` (that → 404).
Runs only under the ``server`` extra (``importorskip``); skipped without cassettes.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from notebooklm.auth import AuthTokens # noqa: E402
from notebooklm.client import NotebookLMClient # noqa: E402
from notebooklm.server.app import create_app # noqa: E402
from tests.integration.conftest import skip_no_cassettes # noqa: E402
from tests.vcr_config import notebooklm_vcr # noqa: E402
from .conftest import TEST_TOKEN # noqa: E402
pytestmark = skip_no_cassettes
#: Recorded placeholder ids (shape-matched by VCR — see module docstring).
_NB = "c3f6285f-1709-44c4-9cd6-e95cf0ea4f5e"
_SRC = "fdfc8ac4-3237-4f2a-8a79-3e24297a7040"
#: A notebook id recorded in ``real_api_get_notebook.yaml`` (asserted by value).
_GET_NB = "167481cd-23a3-4331-9a45-c8948900bf91"
def _mock_auth() -> AuthTokens:
"""Replay-mode auth — values are irrelevant; the cassette supplies the wire."""
return AuthTokens(
cookies={
"SID": "mock_sid",
"HSID": "mock_hsid",
"SSID": "mock_ssid",
"APISID": "mock_apisid",
"SAPISID": "mock_sapisid",
},
csrf_token="mock_csrf_token",
session_id="mock_session_id",
)
@pytest.fixture
def real_authed_client() -> Iterator[TestClient]:
"""A TestClient over an app whose lifespan opens a real ``NotebookLMClient``."""
@asynccontextmanager
async def factory() -> AsyncIterator[NotebookLMClient]:
async with NotebookLMClient(_mock_auth()) as client:
yield client
app = create_app(client_factory=factory)
headers = {"Authorization": f"Bearer {TEST_TOKEN}", "Host": "127.0.0.1"}
with TestClient(
app, headers=headers, client=("127.0.0.1", 5555), raise_server_exceptions=False
) as c:
yield c
class TestNotebooks:
@notebooklm_vcr.use_cassette("cli_notebook_list.yaml")
def test_list(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.get("/v1/notebooks")
assert resp.status_code == 200
notebooks = resp.json()["notebooks"]
assert notebooks
assert isinstance(notebooks[0].get("id"), str) and notebooks[0]["id"]
assert "title" in notebooks[0]
@notebooklm_vcr.use_cassette("real_api_get_notebook.yaml", allow_playback_repeats=True)
def test_get(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.get(f"/v1/notebooks/{_GET_NB}")
assert resp.status_code == 200
body = resp.json()
assert body["id"] == _GET_NB
assert isinstance(body.get("title"), str) and body["title"]
@notebooklm_vcr.use_cassette("cli_notebook_create.yaml", allow_playback_repeats=True)
def test_create(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.post("/v1/notebooks", json={"title": "VCR CLI Test Notebook"})
assert resp.status_code == 201
body = resp.json()
assert isinstance(body.get("id"), str) and body["id"]
assert "title" in body
@notebooklm_vcr.use_cassette("cli_notebook_delete.yaml", allow_playback_repeats=True)
def test_delete(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.delete(f"/v1/notebooks/{_NB}")
assert resp.status_code == 204
assert resp.content == b""
class TestSources:
@notebooklm_vcr.use_cassette("sources_list.yaml", allow_playback_repeats=True)
def test_list(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.get(f"/v1/notebooks/{_NB}/sources")
assert resp.status_code == 200
body = resp.json()
assert body["notebook_id"] == _NB
assert isinstance(body["sources"], list)
@notebooklm_vcr.use_cassette("sources_add_url.yaml", allow_playback_repeats=True)
def test_add_url(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.post(
f"/v1/notebooks/{_NB}/sources/url", json={"url": "https://example.com/x"}
)
assert resp.status_code == 201
assert isinstance(resp.json().get("id"), str)
@notebooklm_vcr.use_cassette("sources_add_text.yaml", allow_playback_repeats=True)
def test_add_text(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.post(
f"/v1/notebooks/{_NB}/sources/text", json={"text": "hello world", "title": "T"}
)
assert resp.status_code == 201
assert isinstance(resp.json().get("id"), str)
@notebooklm_vcr.use_cassette("sources_delete.yaml", allow_playback_repeats=True)
def test_delete(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.delete(f"/v1/notebooks/{_NB}/sources/{_SRC}")
assert resp.status_code == 204
assert resp.content == b""
class TestChatAndArtifacts:
@notebooklm_vcr.use_cassette("chat_ask.yaml", allow_playback_repeats=True)
def test_chat_ask(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.post(
f"/v1/notebooks/{_NB}/chat", json={"question": "What is this notebook about?"}
)
assert resp.status_code == 200
body = resp.json()
assert isinstance(body.get("answer"), str) and body["answer"]
assert body.get("conversation_id")
@notebooklm_vcr.use_cassette("artifacts_list.yaml", allow_playback_repeats=True)
def test_artifacts_list(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.get(f"/v1/notebooks/{_NB}/artifacts")
assert resp.status_code == 200
body = resp.json()
assert body["notebook_id"] == _NB
assert isinstance(body["artifacts"], list)
class TestArtifactLifecycle:
"""The generate → poll → download legs, replayed from server-shaped cassettes
recorded by ``tests/scripts/record_server_cassettes.py``."""
@notebooklm_vcr.use_cassette("server_generate_quiz.yaml", allow_playback_repeats=True)
def test_generate_and_poll(self, real_authed_client: TestClient) -> None:
# No ``source_ids`` → the server defaults to ALL sources (matching the
# CLI). Before that fix this 502'd "Quiz generation is unavailable".
gen = real_authed_client.post(f"/v1/notebooks/{_NB}/artifacts", json={"type": "quiz"})
assert gen.status_code == 202
body = gen.json()
assert body["kind"] == "quiz"
task_id = body["task_id"]
assert isinstance(task_id, str) and task_id
# Poll the freshly-recorded task (the pending registry resolved it).
poll = real_authed_client.get(f"/v1/notebooks/{_NB}/artifacts/{task_id}")
assert poll.status_code == 200
assert poll.json()["notebook_id"] == _NB
@notebooklm_vcr.use_cassette("server_download_mind_map.yaml", allow_playback_repeats=True)
def test_download(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.post(
f"/v1/notebooks/{_NB}/artifacts/download", json={"type": "mind-map"}
)
assert resp.status_code == 200
assert resp.content # streamed artifact bytes
class TestFileUpload:
"""The multipart file-upload leg through the real upload stack (INIT_UPLOAD +
PUT bytes + ADD_SOURCE), replayed from ``server_add_file.yaml``."""
@notebooklm_vcr.use_cassette("server_add_file.yaml", allow_playback_repeats=True)
def test_add_file(self, real_authed_client: TestClient) -> None:
# The upload must reproduce the caller's real basename (the resumable-init
# 400s on an extensionless name and the source-id extraction keys off the
# filename) — regression-guarded here through the real upload stack. The
# filename matches the recording so the recorded registration replays.
files = {"file": ("server-vcr-upload.txt", b"server upload body\n", "text/plain")}
resp = real_authed_client.post(f"/v1/notebooks/{_NB}/sources/file", files=files)
assert resp.status_code == 201
assert isinstance(resp.json().get("id"), str)
class TestErrorProjection:
"""Real client-level error → ``classify`` → HTTP status, via synthetic-error
cassettes (existing recordings reused by the CLI error-contract suite)."""
@notebooklm_vcr.use_cassette("error_synthetic_429_rate_limit.yaml", allow_playback_repeats=True)
def test_rate_limit_maps_to_429(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.get("/v1/notebooks")
assert resp.status_code == 429
assert resp.json()["error"]["category"] == "rate_limited"
@notebooklm_vcr.use_cassette("error_synthetic_500_server.yaml", allow_playback_repeats=True)
def test_server_error_maps_to_502(self, real_authed_client: TestClient) -> None:
resp = real_authed_client.get("/v1/notebooks")
assert resp.status_code == 502