Files
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

399 lines
15 KiB
Python

"""Unit tests for LabelsAPI over a mock RpcCaller (no HTTP/VCR — unit tier)."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from notebooklm._labels import LabelsAPI
from notebooklm.exceptions import LabelError, LabelNotFoundError, UnknownRPCMethodError
from notebooklm.rpc import RPCMethod
def _label_tuple(
name: str, label_id: str, *, emoji: str = "", src: list[str] | None = None
) -> list[Any]:
sources = [[s] for s in src] if src else None
return [name, sources, label_id, emoji]
def _list_env(*tuples: list[Any]) -> list[Any]:
return [list(tuples)] # LIST_LABELS echoes [[label, ...]]
def _create_env(*tuples: list[Any]) -> list[Any]:
return [None, list(tuples)] # CREATE_LABEL echoes [None, [label, ...]]
class FakeRpc:
def __init__(self, responses: dict[RPCMethod, Any] | None = None) -> None:
self.calls: list[SimpleNamespace] = []
self.responses = responses or {}
async def rpc_call(
self,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
allow_null: bool = False,
_is_retry: bool = False,
*,
disable_internal_retries: bool = False,
operation_variant: str | None = None,
) -> Any:
self.calls.append(
SimpleNamespace(
method=method,
params=params,
source_path=source_path,
allow_null=allow_null,
operation_variant=operation_variant,
)
)
return self.responses.get(method)
def methods(self) -> list[RPCMethod]:
return [c.method for c in self.calls]
def _api(responses: dict[RPCMethod, Any] | None = None, sources: list[Any] | None = None):
rpc = FakeRpc(responses)
list_sources = AsyncMock(return_value=sources or [])
return LabelsAPI(rpc, list_sources=list_sources), rpc, list_sources
# -- read --------------------------------------------------------------------
async def test_list_decodes_list_envelope() -> None:
api, rpc, _ = _api({RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1", src=["s1"]))})
labels = await api.list("nb")
assert [label.id for label in labels] == ["l1"]
assert labels[0].source_ids == ["s1"]
assert labels[0].notebook_id == "nb"
assert rpc.methods() == [RPCMethod.LIST_LABELS]
assert rpc.calls[0].source_path == "/notebook/nb"
async def test_generate_decodes_create_envelope_and_default_scope() -> None:
api, rpc, _ = _api(
{RPCMethod.CREATE_LABEL: _create_env(_label_tuple("A", "l1"), _label_tuple("B", "l2"))}
)
labels = await api.generate("nb")
assert {label.id for label in labels} == {"l1", "l2"}
assert rpc.methods() == [RPCMethod.CREATE_LABEL]
assert rpc.calls[0].params[4] == [0] # default scope="unlabeled"
async def test_generate_scope_all_is_destructive_slot() -> None:
api, rpc, _ = _api({RPCMethod.CREATE_LABEL: _create_env()})
await api.generate("nb", scope="all")
assert rpc.calls[0].params[4] == []
async def test_generate_rejects_invalid_scope_before_any_rpc() -> None:
# A runtime-invalid scope must raise ValueError BEFORE the wire — the param
# builder treats anything != "all" as "unlabeled", so without this guard an
# invalid value would silently build the (unintended) "unlabeled" payload.
api, rpc, _ = _api({RPCMethod.CREATE_LABEL: _create_env()})
with pytest.raises(ValueError):
await api.generate("nb", scope="bogus") # type: ignore[arg-type]
assert rpc.calls == []
async def test_list_truthy_non_list_envelope_raises_drift() -> None:
# A present-but-non-list envelope is schema drift, not an empty label set —
# it must raise rather than masking the drift as ``[]``.
api, _, _ = _api({RPCMethod.LIST_LABELS: "unexpected-string"})
with pytest.raises(UnknownRPCMethodError):
await api.list("nb")
async def test_list_and_create_envelopes_differ() -> None:
# Same label payload under each envelope must both decode to the same label.
tuple_ = _label_tuple("A", "l1")
api_l, _, _ = _api({RPCMethod.LIST_LABELS: _list_env(tuple_)})
api_c, _, _ = _api({RPCMethod.CREATE_LABEL: _create_env(tuple_)})
assert (await api_l.list("nb"))[0].id == (await api_c.generate("nb"))[0].id == "l1"
async def test_get_or_none_and_get() -> None:
api, _, _ = _api({RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1"))})
assert (await api.get_or_none("nb", "l1")).id == "l1"
assert await api.get_or_none("nb", "missing") is None
assert (await api.get("nb", "l1")).id == "l1"
async def test_get_raises_label_not_found_with_method_id() -> None:
api, _, _ = _api({RPCMethod.LIST_LABELS: _list_env()})
with pytest.raises(LabelNotFoundError) as exc:
await api.get("nb", "missing")
assert exc.value.label_id == "missing"
assert exc.value.method_id == RPCMethod.LIST_LABELS.value
# -- create id-diff ----------------------------------------------------------
async def test_create_returns_the_new_id() -> None:
api, rpc, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1")),
RPCMethod.CREATE_LABEL: _create_env(_label_tuple("A", "l1"), _label_tuple("New", "l2")),
}
)
new = await api.create("nb", "New")
assert new.id == "l2"
assert rpc.methods() == [RPCMethod.LIST_LABELS, RPCMethod.CREATE_LABEL]
async def test_create_zero_new_raises_label_error() -> None:
api, _, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1")),
RPCMethod.CREATE_LABEL: _create_env(_label_tuple("A", "l1")), # no new id
}
)
with pytest.raises(LabelError):
await api.create("nb", "X")
async def test_create_multiple_new_raises_label_error() -> None:
api, _, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(), # empty baseline
RPCMethod.CREATE_LABEL: _create_env(_label_tuple("A", "l1"), _label_tuple("B", "l2")),
}
)
with pytest.raises(LabelError):
await api.create("nb", "X")
# -- mutate ------------------------------------------------------------------
async def test_rename_preserves_emoji_and_variant_none() -> None:
api, rpc, _ = _api(
{RPCMethod.LIST_LABELS: _list_env(_label_tuple("Old", "l1", emoji="\U0001f4c4"))}
)
await api.rename("nb", "l1", "New")
upd = next(c for c in rpc.calls if c.method == RPCMethod.UPDATE_LABEL)
assert upd.operation_variant is None
assert upd.params[3] == [[["New", "\U0001f4c4"]]] # name + preserved emoji
async def test_set_emoji_sends_null_name_slot_variant_none() -> None:
api, rpc, _ = _api({RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1"))})
await api.set_emoji("nb", "l1", "\U0001f525")
upd = next(c for c in rpc.calls if c.method == RPCMethod.UPDATE_LABEL)
assert upd.operation_variant is None
assert upd.params[3] == [[[None, "\U0001f525"]]]
async def test_add_sources_single_id_is_one_update_plus_refetch() -> None:
api, rpc, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1", src=["s1"])),
RPCMethod.UPDATE_LABEL: [],
}
)
await api.add_sources("nb", "l1", ["s2"])
assert rpc.methods() == [RPCMethod.UPDATE_LABEL, RPCMethod.LIST_LABELS]
upd = rpc.calls[0]
assert upd.operation_variant == "add_sources"
assert upd.params[3] == [[None, [["s2"]]]]
async def test_add_sources_multi_id_issues_one_update_per_id() -> None:
# The wire honours only the first id per call, so a 3-id add MUST be 3
# le8sX calls (one per source), then ONE preflight re-fetch.
api, rpc, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1", src=["a", "b", "c"])),
RPCMethod.UPDATE_LABEL: [],
}
)
await api.add_sources("nb", "l1", ["a", "b", "c"])
assert rpc.methods() == [
RPCMethod.UPDATE_LABEL,
RPCMethod.UPDATE_LABEL,
RPCMethod.UPDATE_LABEL,
RPCMethod.LIST_LABELS,
]
updates = [c for c in rpc.calls if c.method == RPCMethod.UPDATE_LABEL]
assert [u.operation_variant for u in updates] == ["add_sources"] * 3
assert [u.params[3] for u in updates] == [
[[None, [["a"]]]],
[[None, [["b"]]]],
[[None, [["c"]]]],
]
async def test_remove_sources_single_id_is_one_update_plus_refetch() -> None:
api, rpc, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1", src=["s1", "s2"])),
RPCMethod.UPDATE_LABEL: [],
}
)
await api.remove_sources("nb", "l1", ["s2"])
assert rpc.methods() == [RPCMethod.UPDATE_LABEL, RPCMethod.LIST_LABELS]
upd = rpc.calls[0]
assert upd.operation_variant == "remove_sources"
assert upd.params[3] == [[None, None, [["s2"]]]]
async def test_remove_sources_multi_id_issues_one_update_per_id() -> None:
api, rpc, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1", src=["a"])),
RPCMethod.UPDATE_LABEL: [],
}
)
await api.remove_sources("nb", "l1", ["a", "b", "c"])
assert rpc.methods() == [
RPCMethod.UPDATE_LABEL,
RPCMethod.UPDATE_LABEL,
RPCMethod.UPDATE_LABEL,
RPCMethod.LIST_LABELS,
]
updates = [c for c in rpc.calls if c.method == RPCMethod.UPDATE_LABEL]
assert [u.operation_variant for u in updates] == ["remove_sources"] * 3
assert [u.params[3] for u in updates] == [
[[None, None, [["a"]]]],
[[None, None, [["b"]]]],
[[None, None, [["c"]]]],
]
async def test_remove_sources_of_non_member_does_not_raise() -> None:
# Removing an absent member is a no-op on the wire; the API still succeeds
# (the preflight finds the label, just unchanged).
api, _, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1", src=["s1"])),
RPCMethod.UPDATE_LABEL: [],
}
)
out = await api.remove_sources("nb", "l1", ["not-a-member"])
assert out is not None and out.id == "l1"
async def test_mutations_raise_on_missing_even_when_not_returning_object() -> None:
api, _, _ = _api({RPCMethod.LIST_LABELS: _list_env(), RPCMethod.UPDATE_LABEL: []})
with pytest.raises(LabelNotFoundError):
await api.rename("nb", "missing", "X", return_object=False)
with pytest.raises(LabelNotFoundError):
await api.update("nb", "missing", emoji="\U0001f525", return_object=False)
with pytest.raises(LabelNotFoundError):
await api.add_sources("nb", "missing", ["s1"], return_object=False)
with pytest.raises(LabelNotFoundError):
await api.remove_sources("nb", "missing", ["s1"], return_object=False)
async def test_noop_mutations_raise_value_error_before_any_rpc() -> None:
api, rpc, _ = _api()
with pytest.raises(ValueError):
await api.update("nb", "l1") # both name and emoji None
with pytest.raises(ValueError):
await api.add_sources("nb", "l1", [])
with pytest.raises(ValueError):
await api.remove_sources("nb", "l1", [])
assert rpc.calls == [] # nothing reached the wire
# -- delete ------------------------------------------------------------------
async def test_delete_absent_is_idempotent_noop_returns_none() -> None:
api, rpc, _ = _api({RPCMethod.DELETE_LABEL: []})
assert await api.delete("nb", "unknown") is None
assert await api.delete("nb", ["l1", "l2"]) is None
deletes = [c for c in rpc.calls if c.method == RPCMethod.DELETE_LABEL]
assert deletes[0].params[2] == ["unknown"] # str accepted, wrapped in a list
assert deletes[0].allow_null is True
assert deletes[1].params[2] == ["l1", "l2"]
async def test_delete_empty_list_issues_no_rpc() -> None:
api, rpc, _ = _api()
assert await api.delete("nb", []) is None
assert rpc.calls == []
# -- sources join ------------------------------------------------------------
async def test_sources_join_membership_order_skips_missing() -> None:
api, _, list_sources = _api(
{RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1", src=["s2", "s3", "sX"]))},
sources=[SimpleNamespace(id="s1"), SimpleNamespace(id="s2"), SimpleNamespace(id="s3")],
)
out = await api.sources("nb", "l1")
assert [s.id for s in out] == ["s2", "s3"] # membership order; sX (deleted) skipped
list_sources.assert_awaited_once_with("nb")
async def test_sources_missing_label_raises() -> None:
api, _, _ = _api({RPCMethod.LIST_LABELS: _list_env()})
with pytest.raises(LabelNotFoundError):
await api.sources("nb", "missing")
# -- non-atomicity contract (per-id loop) ------------------------------------
async def test_add_sources_is_not_atomic_partial_failure_propagates() -> None:
"""Multi-id add is non-atomic: a mid-loop RPC failure leaves the earlier ids
written and propagates the error without the final re-fetch."""
class _RaiseOnSecondUpdate(FakeRpc):
async def rpc_call(
self,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
allow_null: bool = False,
_is_retry: bool = False,
*,
disable_internal_retries: bool = False,
operation_variant: str | None = None,
) -> Any:
await super().rpc_call(
method,
params,
source_path,
allow_null,
_is_retry,
disable_internal_retries=disable_internal_retries,
operation_variant=operation_variant,
)
if sum(c.method == RPCMethod.UPDATE_LABEL for c in self.calls) == 2:
raise RuntimeError("wire blip on the 2nd add")
return None
rpc = _RaiseOnSecondUpdate()
api = LabelsAPI(rpc, list_sources=AsyncMock(return_value=[]))
with pytest.raises(RuntimeError):
await api.add_sources("nb", "l1", ["s1", "s2", "s3"])
updates = [c for c in rpc.calls if c.method == RPCMethod.UPDATE_LABEL]
assert len(updates) == 2 # first applied, failed on the second, never reached the third
assert all(c.method != RPCMethod.LIST_LABELS for c in rpc.calls) # no final re-fetch
async def test_add_sources_dedupes_ids_preserving_order() -> None:
"""Duplicate ids collapse to one le8sX each (order preserved) — no redundant calls."""
api, rpc, _ = _api(
{
RPCMethod.LIST_LABELS: _list_env(_label_tuple("A", "l1")),
RPCMethod.UPDATE_LABEL: [],
}
)
await api.add_sources("nb", "l1", ["s1", "s2", "s1"])
updates = [c for c in rpc.calls if c.method == RPCMethod.UPDATE_LABEL]
assert [u.params[3] for u in updates] == [[[None, [["s1"]]]], [[None, [["s2"]]]]]