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

510 lines
22 KiB
Python

"""Unit tests for the transport-neutral ``notebooklm._app.source_mutations`` core.
These pin the relocated source-mutation business logic at the ``_app`` boundary
(independent of the Click adapter):
* source-id resolvers — :func:`resolve_source_for_delete` (UUID fast-path,
partial-prefix match, ambiguity, title-instead-of-id hint, not-found) and
:func:`resolve_source_by_exact_title`.
* the typed :class:`SourceMutationError` (carried ``.code`` / ``.extra`` /
``.status_message``) and the :func:`require_yes_in_json` JSON-mode gate.
* the small pure helpers :func:`looks_like_full_source_id` /
:func:`build_id_ambiguity_error`.
* the executors — delete / delete-by-title (confirm + cancel + JSON gate),
rename / refresh (injected ``resolve_source_id``), add-drive (mime mapping).
Pure-service tests (no Click / CliRunner): the command-layer rendering +
exit-code policy is exercised in ``tests/unit/cli/test_source.py`` and
``tests/unit/cli/test_source_cmd_coverage.py``.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._app.resolve import resolve_ref
from notebooklm._app.source_mutations import (
SourceAddDrivePlan,
SourceDeleteByTitlePlan,
SourceDeletePlan,
SourceMutationError,
SourceRefreshPlan,
SourceRenamePlan,
build_id_ambiguity_error,
execute_source_add_drive,
execute_source_delete,
execute_source_delete_by_title,
execute_source_refresh,
execute_source_rename,
looks_like_full_source_id,
require_yes_in_json,
resolve_source_by_exact_title,
resolve_source_for_delete,
)
from notebooklm.exceptions import NotebookLMError, ValidationError
from notebooklm.types import DriveMimeType, Source, SourceType
_FULL_UUID = "11111111-2222-3333-4444-555555555555"
def _client(*, sources: list[Source] | None = None) -> MagicMock:
client = MagicMock()
client.sources = MagicMock()
client.sources.list = AsyncMock(return_value=sources or [])
client.sources.delete = AsyncMock(return_value=None)
client.sources.rename = AsyncMock()
client.sources.refresh = AsyncMock(return_value=None)
client.sources.add_drive = AsyncMock()
return client
# ===========================================================================
# Pure helpers
# ===========================================================================
class TestPureHelpers:
def test_looks_like_full_source_id_accepts_uuid(self) -> None:
assert looks_like_full_source_id(_FULL_UUID) is True
@pytest.mark.parametrize("partial", ["src", "1234", "11111111-2222"])
def test_looks_like_full_source_id_rejects_partial(self, partial: str) -> None:
assert looks_like_full_source_id(partial) is False
def test_build_id_ambiguity_error_lists_matches(self) -> None:
matches = [Source(id="src_aaa111", title="One"), Source(id="src_aaa222", title=None)]
msg = build_id_ambiguity_error("src_aaa", matches)
assert "Ambiguous ID 'src_aaa'" in msg
assert "matches 2 sources" in msg
assert "src_aaa111" in msg
assert "(untitled)" in msg # None title rendered as placeholder
def test_build_id_ambiguity_error_truncates_overflow(self) -> None:
matches = [Source(id=f"src_{i:06d}", title=f"T{i}") for i in range(7)]
msg = build_id_ambiguity_error("src", matches)
assert "... and 2 more" in msg
# ===========================================================================
# SourceMutationError + require_yes_in_json
# ===========================================================================
class TestSourceMutationError:
def test_is_notebooklm_error_subclass(self) -> None:
assert issubclass(SourceMutationError, NotebookLMError)
def test_carries_code_and_extra(self) -> None:
err = SourceMutationError("boom", "NOT_FOUND", {"source_id": "s"}, "hint")
assert err.code == "NOT_FOUND"
assert err.extra == {"source_id": "s"}
assert err.status_message == "hint"
# The metadata is embedded in the str message.
assert "code=NOT_FOUND" in str(err)
assert "extra=" in str(err)
def test_no_extra_omits_extra_from_message(self) -> None:
err = SourceMutationError("boom", "AMBIGUOUS_ID")
assert "code=AMBIGUOUS_ID" in str(err)
assert "extra=" not in str(err)
def test_require_yes_in_json_raises_confirm_required(self) -> None:
with pytest.raises(SourceMutationError) as exc:
require_yes_in_json(action="delete", extra={"source_id": "s"}, status_message="hint")
err = exc.value
assert err.code == "CONFIRM_REQUIRED"
assert err.extra == {"action": "delete", "source_id": "s"}
assert err.status_message == "hint"
# ===========================================================================
# resolve_source_for_delete
# ===========================================================================
class TestResolveSourceForDelete:
@pytest.mark.asyncio
async def test_full_uuid_skips_list(self) -> None:
client = _client()
resolution = await resolve_source_for_delete(client, "nb_1", _FULL_UUID)
assert resolution.source_id == _FULL_UUID
assert resolution.status_message is None
client.sources.list.assert_not_called()
@pytest.mark.asyncio
async def test_unique_partial_match_resolves_with_status(self) -> None:
client = _client(sources=[Source(id="src_aaa111", title="One")])
resolution = await resolve_source_for_delete(client, "nb_1", "src_aaa")
assert resolution.source_id == "src_aaa111"
# A partial→full expansion surfaces the "Matched:" status prose.
assert resolution.status_message is not None
assert "Matched:" in resolution.status_message
@pytest.mark.asyncio
async def test_exact_partial_match_no_status(self) -> None:
# When the input already equals the matched id, no status prose is emitted.
client = _client(sources=[Source(id="src_aaa111", title="One")])
resolution = await resolve_source_for_delete(client, "nb_1", "src_aaa111")
assert resolution.source_id == "src_aaa111"
assert resolution.status_message is None
@pytest.mark.asyncio
async def test_exact_match_wins_over_prefix_ambiguity(self) -> None:
# "abc" is an exact (case-insensitive) match for the first source AND a
# strict prefix of "abcdef" — exact must win and not report ambiguity,
# mirroring resolve_ref / resolve_partial_id_in_items Rule 3 so delete
# stays in lockstep with get/rename/refresh (issue #1522).
client = _client(
sources=[Source(id="abc", title="Exact"), Source(id="abcdef", title="Prefixed")]
)
resolution = await resolve_source_for_delete(client, "nb_1", "abc")
assert resolution.source_id == "abc"
# An exact match is not a partial expansion, so no "Matched:" prose.
assert resolution.status_message is None
@pytest.mark.asyncio
async def test_exact_match_wins_case_insensitive(self) -> None:
# Exact match is case-insensitive and returns the source's canonical id.
client = _client(
sources=[Source(id="ABC", title="Exact"), Source(id="abcdef", title="Prefixed")]
)
resolution = await resolve_source_for_delete(client, "nb_1", "abc")
assert resolution.source_id == "ABC"
assert resolution.status_message is None
@pytest.mark.asyncio
async def test_ambiguous_partial_raises(self) -> None:
client = _client(
sources=[Source(id="src_aaa111", title="One"), Source(id="src_aaa222", title="Two")]
)
with pytest.raises(SourceMutationError) as exc:
await resolve_source_for_delete(client, "nb_1", "src_aaa")
assert exc.value.code == "AMBIGUOUS_ID"
@pytest.mark.asyncio
async def test_genuine_ambiguity_without_exact_still_raises(self) -> None:
# Two strict prefixes and NO exact match → genuine ambiguity is preserved.
client = _client(
sources=[Source(id="abc111", title="One"), Source(id="abc222", title="Two")]
)
with pytest.raises(SourceMutationError) as exc:
await resolve_source_for_delete(client, "nb_1", "abc")
assert exc.value.code == "AMBIGUOUS_ID"
@pytest.mark.asyncio
async def test_title_match_suggests_delete_by_title(self) -> None:
client = _client(sources=[Source(id="src_xyz999", title="My Title")])
with pytest.raises(SourceMutationError) as exc:
await resolve_source_for_delete(client, "nb_1", "My Title")
assert exc.value.code == "VALIDATION_ERROR"
assert "delete-by-title" in str(exc.value)
@pytest.mark.asyncio
async def test_no_match_raises_not_found(self) -> None:
client = _client(sources=[Source(id="src_aaa111", title="One")])
with pytest.raises(SourceMutationError) as exc:
await resolve_source_for_delete(client, "nb_1", "zzz")
assert exc.value.code == "NOT_FOUND"
@pytest.mark.asyncio
async def test_exact_match_parity_with_shared_resolver(self) -> None:
# Parity guard (issue #1522): delete's bespoke resolver must agree with
# the canonical resolve_ref on the exact-match-wins outcome, the rule
# get/rename/refresh already get for free via resolve_source_id.
sources = [Source(id="abc", title="Exact"), Source(id="abcdef", title="Prefixed")]
client = _client(sources=sources)
resolution = await resolve_source_for_delete(client, "nb_1", "abc")
shared = resolve_ref("abc", sources, id_of=lambda s: s.id, title_of=lambda s: s.title)
assert resolution.source_id == shared.id == "abc"
@pytest.mark.asyncio
async def test_injected_validate_id_runs_first(self) -> None:
client = _client()
called: list[tuple[str, str]] = []
def validate_id(value: str, kind: str) -> str:
called.append((value, kind))
return value.strip()
await resolve_source_for_delete(
client, "nb_1", f" {_FULL_UUID} ", validate_id=validate_id
)
assert called == [(f" {_FULL_UUID} ", "source")]
# ===========================================================================
# resolve_source_by_exact_title
# ===========================================================================
class TestResolveSourceByExactTitle:
@pytest.mark.asyncio
async def test_single_title_match(self) -> None:
client = _client(sources=[Source(id="src_1", title="My Title")])
src = await resolve_source_by_exact_title(client, "nb_1", "My Title")
assert src.id == "src_1"
@pytest.mark.asyncio
async def test_duplicate_titles_raise_ambiguous(self) -> None:
client = _client(sources=[Source(id="src_1", title="Dup"), Source(id="src_2", title="Dup")])
with pytest.raises(SourceMutationError) as exc:
await resolve_source_by_exact_title(client, "nb_1", "Dup")
assert exc.value.code == "AMBIGUOUS_TITLE"
@pytest.mark.asyncio
async def test_no_title_match_raises_not_found(self) -> None:
client = _client(sources=[Source(id="src_1", title="Other")])
with pytest.raises(SourceMutationError) as exc:
await resolve_source_by_exact_title(client, "nb_1", "Missing")
assert exc.value.code == "NOT_FOUND"
# ===========================================================================
# execute_source_delete
# ===========================================================================
class TestExecuteSourceDelete:
@pytest.mark.asyncio
async def test_delete_with_yes_completes(self) -> None:
client = _client()
confirm = MagicMock() # never consulted under yes=True
plan = SourceDeletePlan(
notebook_id="nb_1", source_id=_FULL_UUID, yes=True, json_output=False
)
result = await execute_source_delete(client, plan, confirmer=confirm)
assert result.success is True
assert result.status == "completed"
client.sources.delete.assert_awaited_once_with("nb_1", _FULL_UUID)
confirm.assert_not_called()
@pytest.mark.asyncio
async def test_delete_declined_confirmation_cancels(self) -> None:
client = _client()
plan = SourceDeletePlan(
notebook_id="nb_1", source_id=_FULL_UUID, yes=False, json_output=False
)
result = await execute_source_delete(client, plan, confirmer=lambda _msg: False)
assert result.success is False
assert result.status == "cancelled"
client.sources.delete.assert_not_called()
@pytest.mark.asyncio
async def test_delete_confirmed_interactively_completes(self) -> None:
client = _client()
plan = SourceDeletePlan(
notebook_id="nb_1", source_id=_FULL_UUID, yes=False, json_output=False
)
result = await execute_source_delete(client, plan, confirmer=lambda _msg: True)
assert result.success is True
client.sources.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_json_with_yes_bypasses_gate(self) -> None:
client = _client()
plan = SourceDeletePlan(
notebook_id="nb_1", source_id=_FULL_UUID, yes=True, json_output=True
)
# yes=True bypasses the gate even in json mode → completes.
result = await execute_source_delete(client, plan, confirmer=MagicMock())
assert result.success is True
client.sources.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_json_no_yes_raises_confirm_required(self) -> None:
client = _client()
plan = SourceDeletePlan(
notebook_id="nb_1", source_id=_FULL_UUID, yes=False, json_output=True
)
with pytest.raises(SourceMutationError) as exc:
await execute_source_delete(client, plan, confirmer=MagicMock())
assert exc.value.code == "CONFIRM_REQUIRED"
assert exc.value.extra is not None
assert exc.value.extra["action"] == "delete"
client.sources.delete.assert_not_called()
# ===========================================================================
# execute_source_delete_by_title
# ===========================================================================
class TestExecuteSourceDeleteByTitle:
@pytest.mark.asyncio
async def test_delete_by_title_with_yes_completes(self) -> None:
client = _client(sources=[Source(id="src_1", title="Doc")])
plan = SourceDeleteByTitlePlan(notebook_id="nb_1", title="Doc", yes=True, json_output=False)
result = await execute_source_delete_by_title(client, plan, confirmer=MagicMock())
assert result.success is True
assert result.source_id == "src_1"
assert result.title == "Doc"
client.sources.delete.assert_awaited_once_with("nb_1", "src_1")
@pytest.mark.asyncio
async def test_delete_by_title_declined_cancels(self) -> None:
client = _client(sources=[Source(id="src_1", title="Doc")])
plan = SourceDeleteByTitlePlan(
notebook_id="nb_1", title="Doc", yes=False, json_output=False
)
result = await execute_source_delete_by_title(client, plan, confirmer=lambda _m: False)
assert result.success is False
assert result.status == "cancelled"
client.sources.delete.assert_not_called()
@pytest.mark.asyncio
async def test_delete_by_title_json_no_yes_raises(self) -> None:
client = _client(sources=[Source(id="src_1", title="Doc")])
plan = SourceDeleteByTitlePlan(notebook_id="nb_1", title="Doc", yes=False, json_output=True)
with pytest.raises(SourceMutationError) as exc:
await execute_source_delete_by_title(client, plan, confirmer=MagicMock())
assert exc.value.code == "CONFIRM_REQUIRED"
assert exc.value.extra is not None
assert exc.value.extra["action"] == "delete-by-title"
# ===========================================================================
# execute_source_rename
# ===========================================================================
@pytest.mark.asyncio
async def test_rename_resolves_then_renames() -> None:
client = _client()
renamed = Source(id="src_full", title="New Name")
client.sources.rename = AsyncMock(return_value=renamed)
resolve = AsyncMock(return_value="src_full")
plan = SourceRenamePlan(
notebook_id="nb_1", source_id="src", new_title="New Name", json_output=False
)
result = await execute_source_rename(client, plan, resolve_source_id=resolve)
assert result.source is renamed
assert result.notebook_id == "nb_1"
resolve.assert_awaited_once_with(client, "nb_1", "src", json_output=False)
client.sources.rename.assert_awaited_once_with("nb_1", "src_full", "New Name")
# ===========================================================================
# execute_source_refresh
# ===========================================================================
@pytest.mark.asyncio
async def test_refresh_resolves_then_refreshes() -> None:
client = _client()
resolve = AsyncMock(return_value="src_full")
plan = SourceRefreshPlan(notebook_id="nb_1", source_id="src", json_output=False)
result = await execute_source_refresh(client, plan, resolve_source_id=resolve)
assert result.source_id == "src_full"
assert result.notebook_id == "nb_1"
assert result.result is None
resolve.assert_awaited_once_with(client, "nb_1", "src", json_output=False)
client.sources.refresh.assert_awaited_once_with("nb_1", "src_full")
# ===========================================================================
# execute_source_add_drive — mime mapping
# ===========================================================================
@pytest.mark.asyncio
@pytest.mark.parametrize(
("choice", "expected_mime", "expected_kind"),
[
("google-doc", DriveMimeType.GOOGLE_DOC.value, SourceType.GOOGLE_DOCS),
("google-slides", DriveMimeType.GOOGLE_SLIDES.value, SourceType.GOOGLE_SLIDES),
("google-sheets", DriveMimeType.GOOGLE_SHEETS.value, SourceType.GOOGLE_SPREADSHEET),
("pdf", DriveMimeType.PDF.value, SourceType.PDF),
],
)
async def test_add_drive_maps_mime(
choice: str, expected_mime: str, expected_kind: SourceType
) -> None:
client = _client()
added = Source(id="src_drive", title="Drive Doc")
client.sources.add_drive = AsyncMock(return_value=added)
plan = SourceAddDrivePlan(
notebook_id="nb_1",
file_id="fid",
title="Drive Doc",
mime_type=choice, # type: ignore[arg-type]
)
result = await execute_source_add_drive(client, plan)
assert result.source is added
assert result.file_id == "fid"
assert result.mime_type == choice
# The declared mime stamps the source's kind (#1828).
assert result.source.kind == expected_kind
client.sources.add_drive.assert_awaited_once_with("nb_1", "fid", "Drive Doc", expected_mime)
def test_drive_mime_maps_have_matching_keys() -> None:
"""The two Drive-mime dicts are both keyed by ``DriveMimeChoice`` and indexed on
the same add path: validation checks ``_DRIVE_MIME_MAP`` but the type-code stamp
indexes ``_DRIVE_MIME_SOURCE_TYPE``. If a future choice is added to one but not the
other, a validated add would raise ``KeyError`` (→ UNEXPECTED) instead of the clean
``VALIDATION`` the code promises. Guard their key sets together (a module-level
``assert`` would be stripped under ``python -O``)."""
from notebooklm._app.source_mutations import _DRIVE_MIME_MAP, _DRIVE_MIME_SOURCE_TYPE
assert _DRIVE_MIME_MAP.keys() == _DRIVE_MIME_SOURCE_TYPE.keys()
def test_source_type_to_code_matches_decoder_map() -> None:
"""``_SOURCE_TYPE_TO_CODE`` is pinned by hand (the ``_app`` boundary forbids
importing the private decoder map to invert it), so guard it against drift: each
(SourceType → code) entry must round-trip through the canonical decoder map."""
from notebooklm._app.source_mutations import _SOURCE_TYPE_TO_CODE
from notebooklm._types.sources import _SOURCE_TYPE_CODE_MAP
for source_type, code in _SOURCE_TYPE_TO_CODE.items():
assert _SOURCE_TYPE_CODE_MAP[code] == source_type
@pytest.mark.asyncio
async def test_add_drive_pdf_not_mislabeled_as_spreadsheet() -> None:
"""A Drive PDF add must not surface as ``kind='google_spreadsheet'`` (#1828).
The NotebookLM backend returns an ambiguous type code for Drive-hosted PDFs —
code ``14``, which the client otherwise maps to GOOGLE_SPREADSHEET. The declared
``mime_type='pdf'`` is authoritative, so ``execute_source_add_drive`` re-stamps
the returned source's type code to PDF.
"""
client = _client()
# Simulate the backend's ambiguous code (14 → GOOGLE_SPREADSHEET) on the raw add.
added = Source(id="src_pdf", title="Report.pdf", _type_code=14)
assert added.kind is SourceType.GOOGLE_SPREADSHEET # the bug, pre-stamp
client.sources.add_drive = AsyncMock(return_value=added)
plan = SourceAddDrivePlan(
notebook_id="nb_1",
file_id="fid",
title="Report.pdf",
mime_type="pdf",
)
result = await execute_source_add_drive(client, plan)
assert result.source.kind == SourceType.PDF
assert result.source.kind != SourceType.GOOGLE_SPREADSHEET
@pytest.mark.asyncio
async def test_add_drive_bad_mime_raises_validation_error() -> None:
"""An unknown ``mime_type`` raises the public ``ValidationError`` (ADR-0021).
The CLI never reaches this guard (Click validates the ``Choice`` first), but
a transport adapter that forwards a raw string (MCP/HTTP) must get a clean
``VALIDATION`` rather than a raw ``KeyError`` leaking as ``UNEXPECTED``.
"""
client = _client()
plan = SourceAddDrivePlan(
notebook_id="nb_1",
file_id="fid",
title="Drive Doc",
mime_type="bogus", # type: ignore[arg-type]
)
with pytest.raises(ValidationError) as excinfo:
await execute_source_add_drive(client, plan)
# The message lists the valid keys so the caller can self-correct.
assert "google-doc" in str(excinfo.value)
client.sources.add_drive.assert_not_called()