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

283 lines
10 KiB
Python

"""Unit tests for the transport-neutral ``notebooklm._app.notebooks`` core.
These pin the Click-free notebook workflows at the ``_app`` boundary with a
``MagicMock`` client + an injected partial-id resolver (the CLI normally
injects ``cli.resolve.resolve_notebook_id``):
* ``create`` / ``delete`` / ``rename`` / ``describe`` (summary) / ``metadata``
executors delegate to the right ``client.notebooks`` RPC and project the typed
result dataclasses,
* the resolver is threaded through ``rename`` / ``describe`` / ``metadata`` and
the resolved id flows into the downstream RPC,
* ``describe`` tolerates a ``None`` description (the CLI renders both views from
the typed field).
The CLI tests keep ownership of the ``--use`` context side effect, the
serializers, the ``--json`` envelopes, and the error-category exit codes (the
generic error classification is covered by ``app/test_app_errors.py``).
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._app.notebooks import (
NotebookCreateResult,
NotebookDescribeResult,
NotebookMetadataResult,
NotebookRenameResult,
execute_notebook_create,
execute_notebook_delete,
execute_notebook_describe,
execute_notebook_metadata,
execute_notebook_rename,
)
from notebooklm.exceptions import NotebookNotFoundError
from notebooklm.types import (
Notebook,
NotebookDescription,
NotebookMetadata,
SuggestedTopic,
)
def _client() -> MagicMock:
client = MagicMock()
client.notebooks = MagicMock()
return client
async def _resolve_nb(_client, nb_id, *, json_output=False):
"""Identity resolver that prefixes to verify the *resolved* id flows downstream."""
return f"full_{nb_id}"
_CREATED_AT = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
_MODIFIED_AT = datetime(2026, 1, 3, 4, 5, 6, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# create
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_notebook_create_projects_notebook() -> None:
client = _client()
# Both timestamps already populated → the best-effort backfill is a no-op
# (no wasted GET re-read), so this stays a pure projection assertion.
notebook = Notebook(
id="nb_new",
title="My notebook",
created_at=_CREATED_AT,
modified_at=_MODIFIED_AT,
)
client.notebooks.create = AsyncMock(return_value=notebook)
result = await execute_notebook_create(client, "My notebook")
assert isinstance(result, NotebookCreateResult)
assert result.notebook is notebook
client.notebooks.create.assert_awaited_once_with("My notebook")
client.notebooks.get.assert_not_called()
# The create RPC (CREATE_NOTEBOOK / CCqFvf) returns null created_at/modified_at;
# the core does ONE best-effort GET re-read to backfill just those two slots so
# every adapter (CLI/REST/MCP) surfaces populated timestamps on create (#1705,
# lifting the MCP-only #1699 fix into the transport-neutral home).
@pytest.mark.asyncio
async def test_execute_notebook_create_backfills_null_timestamps() -> None:
client = _client()
created = Notebook(id="nb_new", title="New", sources_count=0, is_owner=True)
client.notebooks.create = AsyncMock(return_value=created)
# The GET diverges on every non-timestamp field to prove ONLY the two
# timestamp slots are taken from it; the create stays authoritative.
client.notebooks.get = AsyncMock(
return_value=Notebook(
id="nb_new",
title="Stale",
created_at=_CREATED_AT,
sources_count=9,
is_owner=False,
modified_at=_MODIFIED_AT,
)
)
nb = (await execute_notebook_create(client, "New")).notebook
assert nb.created_at == _CREATED_AT # backfilled from GET
assert nb.modified_at == _MODIFIED_AT # backfilled from GET
assert nb.title == "New" # from create, not the divergent GET
assert nb.sources_count == 0 # from create
assert nb.is_owner is True # from create
client.notebooks.get.assert_awaited_once_with("nb_new")
@pytest.mark.asyncio
async def test_execute_notebook_create_fills_only_the_null_slot() -> None:
"""Per-key + additive: a populated create timestamp is never overwritten."""
client = _client()
created = Notebook(id="nb_new", title="New", created_at=_CREATED_AT) # modified_at None
client.notebooks.create = AsyncMock(return_value=created)
client.notebooks.get = AsyncMock(
return_value=Notebook(
id="nb_new",
title="New",
created_at=datetime(2099, 1, 1, tzinfo=timezone.utc), # must NOT win
modified_at=_MODIFIED_AT,
)
)
nb = (await execute_notebook_create(client, "New")).notebook
assert nb.created_at == _CREATED_AT # create's value preserved, GET ignored
assert nb.modified_at == _MODIFIED_AT # only the null slot filled from GET
client.notebooks.get.assert_awaited_once_with("nb_new")
@pytest.mark.asyncio
async def test_execute_notebook_create_reread_failure_falls_back() -> None:
"""A failed GET re-read must not fail a successful create (best-effort)."""
client = _client()
client.notebooks.create = AsyncMock(return_value=Notebook(id="nb_new", title="New"))
client.notebooks.get = AsyncMock(side_effect=NotebookNotFoundError("nb_new"))
nb = (await execute_notebook_create(client, "New")).notebook # must not raise
assert nb.created_at is None
assert nb.modified_at is None
client.notebooks.get.assert_awaited_once_with("nb_new")
@pytest.mark.asyncio
async def test_execute_notebook_create_reread_still_null_stays_null() -> None:
"""If the GET itself still has null timestamps (propagation lag), stay null."""
client = _client()
client.notebooks.create = AsyncMock(return_value=Notebook(id="nb_new", title="New"))
client.notebooks.get = AsyncMock(return_value=Notebook(id="nb_new", title="New"))
nb = (await execute_notebook_create(client, "New")).notebook
assert nb.created_at is None
assert nb.modified_at is None
client.notebooks.get.assert_awaited_once_with("nb_new")
@pytest.mark.asyncio
async def test_execute_notebook_create_empty_id_skips_reread() -> None:
"""No id → no ``get("")``; the create result is returned untouched."""
client = _client()
client.notebooks.create = AsyncMock(return_value=Notebook(id="", title="New"))
client.notebooks.get = AsyncMock()
nb = (await execute_notebook_create(client, "New")).notebook
assert nb.created_at is None
client.notebooks.get.assert_not_awaited()
# ---------------------------------------------------------------------------
# delete
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_notebook_delete_delegates_to_client() -> None:
client = _client()
client.notebooks.delete = AsyncMock(return_value=None)
# ``delete()`` returns None and raises on failure (issue #1211); reaching
# here without an exception is the success contract.
await execute_notebook_delete(client, "nb_1")
client.notebooks.delete.assert_awaited_once_with("nb_1")
@pytest.mark.asyncio
async def test_execute_notebook_delete_propagates_failure() -> None:
"""``delete()`` raises on real failure (issue #1211); the core does not swallow it."""
client = _client()
client.notebooks.delete = AsyncMock(side_effect=RuntimeError("boom"))
with pytest.raises(RuntimeError, match="boom"):
await execute_notebook_delete(client, "nb_1")
# ---------------------------------------------------------------------------
# rename — resolver threading
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_notebook_rename_resolves_then_renames() -> None:
client = _client()
client.notebooks.rename = AsyncMock(return_value=None)
result = await execute_notebook_rename(
client, "nb_part", "New title", resolve_notebook_id=_resolve_nb
)
assert isinstance(result, NotebookRenameResult)
assert result.notebook_id == "full_nb_part"
assert result.new_title == "New title"
# The *resolved* id flows into the rename RPC, not the partial input.
client.notebooks.rename.assert_awaited_once_with("full_nb_part", "New title")
# ---------------------------------------------------------------------------
# describe (summary)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_notebook_describe_returns_description() -> None:
client = _client()
description = NotebookDescription(
summary="A summary",
suggested_topics=[SuggestedTopic(question="Q?", prompt="Ask Q")],
)
client.notebooks.get_description = AsyncMock(return_value=description)
result = await execute_notebook_describe(client, "nb_part", resolve_notebook_id=_resolve_nb)
assert isinstance(result, NotebookDescribeResult)
assert result.notebook_id == "full_nb_part"
assert result.description is description
client.notebooks.get_description.assert_awaited_once_with("full_nb_part")
@pytest.mark.asyncio
async def test_execute_notebook_describe_tolerates_none_description() -> None:
client = _client()
client.notebooks.get_description = AsyncMock(return_value=None)
result = await execute_notebook_describe(client, "nb_1", resolve_notebook_id=_resolve_nb)
assert result.description is None
# ---------------------------------------------------------------------------
# metadata
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_notebook_metadata_returns_metadata() -> None:
client = _client()
notebook = Notebook(id="full_nb_part", title="My notebook")
metadata = NotebookMetadata(notebook=notebook, sources=[])
client.notebooks.get_metadata = AsyncMock(return_value=metadata)
result = await execute_notebook_metadata(client, "nb_part", resolve_notebook_id=_resolve_nb)
assert isinstance(result, NotebookMetadataResult)
assert result.notebook_id == "full_nb_part"
assert result.metadata is metadata
client.notebooks.get_metadata.assert_awaited_once_with("full_nb_part")