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
148 lines
5.9 KiB
Python
148 lines
5.9 KiB
Python
"""Integration-via-httpx-mock tests for saved-from-chat notes (issue #660).
|
|
|
|
These exercise the full ``NotebookLMClient.chat.save_answer_as_note()``
|
|
path: the encoder builds the 7-element params, ``RpcExecutor.rpc_call``
|
|
wraps them in the batchexecute envelope and POSTs to the server, and we
|
|
assert (a) the wire body matches our captured request byte-for-byte and
|
|
(b) the returned ``Note`` is parsed correctly from the captured response.
|
|
|
|
A VCR cassette would be the canonical fixture for this (per
|
|
``tests/cassettes/notes_create*.yaml``), but recording a fresh cassette
|
|
requires a live auth session against the real service. The captured
|
|
request/response pair under ``tests/unit/fixtures/`` is the next-best
|
|
thing — it carries the exact wire payload Google's web UI sends when
|
|
its "Save to note" button is clicked.
|
|
|
|
Moved from ``tests/unit/`` to ``tests/integration/``.
|
|
Mock-backed (``pytest_httpx``); ``allow_no_vcr`` opts out of the
|
|
integration-tree VCR enforcement hook in ``tests/integration/conftest.py``.
|
|
Fixture JSON files remain under ``tests/unit/fixtures/`` because
|
|
``tests/unit/test_save_chat_as_note_encoder.py`` still reads them; the
|
|
``FIXTURES_DIR`` constant below points at that shared location.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs
|
|
|
|
import pytest
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from notebooklm.client import NotebookLMClient
|
|
from notebooklm.rpc import RPCMethod
|
|
from notebooklm.types import AskResult, ChatReference
|
|
|
|
pytestmark = pytest.mark.allow_no_vcr
|
|
|
|
# Fixtures stay under ``tests/unit/fixtures/`` so the sibling encoder test
|
|
# (``tests/unit/test_save_chat_as_note_encoder.py``) keeps a local
|
|
# ``Path(__file__).parent / "fixtures"`` resolution. From
|
|
# ``tests/integration/`` we hop up two levels and back down.
|
|
FIXTURES_DIR = Path(__file__).resolve().parent.parent / "unit" / "fixtures"
|
|
|
|
|
|
def _load_request_params() -> list:
|
|
return json.loads((FIXTURES_DIR / "save_chat_as_note_create_note_request.json").read_text())[
|
|
"params"
|
|
]
|
|
|
|
|
|
def _load_response_note() -> list:
|
|
return json.loads((FIXTURES_DIR / "save_chat_as_note_create_note_response.json").read_text())[
|
|
"note"
|
|
]
|
|
|
|
|
|
def _build_ask_result_from_request_params(params: list) -> AskResult:
|
|
"""Reconstruct the ``AskResult`` the web UI would have started from
|
|
so the encoder can re-produce exactly the captured request."""
|
|
notebook_id = params[0] # noqa: F841 — kept for parallel-fixture readability
|
|
answer_text = params[1]
|
|
passage = params[3][0]
|
|
cited_text = passage[4][0][0][2][0][0][2][0]
|
|
start_char = passage[3][0][1]
|
|
end_char = passage[3][0][2]
|
|
passage_id = passage[5][0][0][0]
|
|
source_id = passage[5][0][1]
|
|
chunk_id = passage[6][0]
|
|
|
|
return AskResult(
|
|
answer=answer_text,
|
|
conversation_id="captured-conv-id",
|
|
turn_number=1,
|
|
is_follow_up=False,
|
|
references=[
|
|
ChatReference(
|
|
source_id=source_id,
|
|
citation_number=1,
|
|
cited_text=cited_text,
|
|
start_char=start_char,
|
|
end_char=end_char,
|
|
chunk_id=chunk_id,
|
|
passage_id=passage_id,
|
|
)
|
|
],
|
|
raw_response="",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_answer_as_note_wire_round_trip(
|
|
auth_tokens,
|
|
httpx_mock: HTTPXMock,
|
|
build_rpc_response,
|
|
):
|
|
"""End-to-end: client.chat.save_answer_as_note() POSTs the captured
|
|
wire format and parses the captured response into a Note."""
|
|
request_params = _load_request_params()
|
|
response_note = _load_response_note()
|
|
notebook_id = request_params[0]
|
|
requested_title = request_params[4]
|
|
|
|
# Mock the CREATE_NOTE RPC response: server returns the note as a
|
|
# single-element list (wrapped in the wrb.fr envelope by the helper).
|
|
response_body = build_rpc_response(RPCMethod.CREATE_NOTE, [response_note])
|
|
httpx_mock.add_response(content=response_body.encode())
|
|
|
|
ask_result = _build_ask_result_from_request_params(request_params)
|
|
|
|
async with NotebookLMClient(auth_tokens) as client:
|
|
note = await client.chat.save_answer_as_note(notebook_id, ask_result, title=requested_title)
|
|
|
|
# Assert the request body carried the exact captured params.
|
|
request = httpx_mock.get_request()
|
|
body = request.content.decode()
|
|
form = parse_qs(body)
|
|
f_req = form["f.req"][0]
|
|
outer = json.loads(f_req)
|
|
rpc_id, inner_params_json, _null, _generic = outer[0][0]
|
|
assert rpc_id == RPCMethod.CREATE_NOTE.value
|
|
actual_params = json.loads(inner_params_json)
|
|
# Deep-equal first for a clear diff on failure, then byte-exact
|
|
# string compare to catch boolean/int divergence that == would miss
|
|
# (Python's `False == 0` quirk; the wire payload uses 0 not false).
|
|
assert actual_params == request_params
|
|
assert json.dumps(actual_params, separators=(",", ":")) == json.dumps(
|
|
request_params, separators=(",", ":")
|
|
)
|
|
|
|
# Assert the Note dataclass was populated from the response.
|
|
# Server returns a different (smart-generated) title in slot [4];
|
|
# the returned Note reflects what the server stored, not what we
|
|
# requested — see issue #660 PR description.
|
|
assert note.id == response_note[0]
|
|
assert note.title == response_note[4]
|
|
assert note.notebook_id == notebook_id
|
|
# Content is the answer text WITH [N] markers (rich anchors live server-side).
|
|
assert note.content == ask_result.answer
|
|
# The captured response carries the creation timestamp in the note metadata
|
|
# envelope (``note[2][2][0]``); save_answer_as_note now decodes it through
|
|
# NoteRow.created_at (issue #1529). Pin the EPOCH INT (TZ-invariant) — the
|
|
# fixture's [1778936820, 976814000] timestamp — never the wall-time string.
|
|
expected_epoch = response_note[2][2][0]
|
|
assert expected_epoch == 1778936820
|
|
assert note.created_at is not None
|
|
assert int(note.created_at.timestamp()) == expected_epoch
|