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

459 lines
16 KiB
Python

"""Tests for ``notebooklm._app.session`` — the transport-neutral session core.
Covers the three Click-free workflows that back ``use`` / ``status`` /
``auth logout``:
* :func:`verify_and_set_notebook` — resolve (injected) → ``notebooks.get`` → typed
:class:`UseNotebookResult`, with the resolver/json-output forwarding asserted.
* :func:`read_status` — pure read+project of ``context.json`` into a
:class:`StatusReport` (no-context, readable-payload, and unreadable-payload
branches; ``--paths``/env-auth pass-through).
* :func:`execute_logout` — the storage-unlink → browser-rmtree → clear_context
teardown ordering and the OSError → :class:`LogoutFailure` mapping per step,
including the **lazy** ``context_path`` timing guard (a raising context-path
callable must NOT abort logout before the storage/browser steps run).
Direct ``_app`` calls only — a ``MagicMock`` client + injected collaborators,
no Click / CliRunner; CLI adapter tests own command rendering and exit-code
behavior.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import FrozenInstanceError
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._app.session import (
LogoutFailure,
LogoutInputs,
LogoutOutcome,
StatusContext,
StatusInputs,
StatusReport,
UseNotebookResult,
execute_logout,
read_status,
verify_and_set_notebook,
)
from notebooklm.types import Notebook
# ---------------------------------------------------------------------------
# verify_and_set_notebook
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_verify_and_set_notebook_resolves_then_gets() -> None:
notebook = Notebook(id="nb_full_123", title="My Notebook")
client = MagicMock()
client.notebooks = MagicMock()
client.notebooks.get = AsyncMock(return_value=notebook)
resolver = AsyncMock(return_value="nb_full_123")
result = await verify_and_set_notebook(
client,
"nb_full",
json_output=False,
resolve_notebook_id=resolver,
)
assert isinstance(result, UseNotebookResult)
assert result.notebook is notebook
assert result.resolved_id == "nb_full_123"
# Resolver is called with the partial id + json-output forwarding.
resolver.assert_awaited_once_with(client, "nb_full", json_output=False)
# The resolved (not partial) id is what's verified against the server.
client.notebooks.get.assert_awaited_once_with("nb_full_123")
@pytest.mark.asyncio
async def test_verify_and_set_notebook_forwards_json_output_flag() -> None:
"""``json_output`` is forwarded so the resolver routes diagnostics to stderr."""
client = MagicMock()
client.notebooks = MagicMock()
client.notebooks.get = AsyncMock(return_value=Notebook(id="nb_1", title="t"))
resolver = AsyncMock(return_value="nb_1")
await verify_and_set_notebook(client, "nb", json_output=True, resolve_notebook_id=resolver)
resolver.assert_awaited_once_with(client, "nb", json_output=True)
@pytest.mark.asyncio
async def test_verify_and_set_notebook_propagates_resolver_error() -> None:
"""A resolver ambiguity / no-match error propagates to the adapter unchanged."""
client = MagicMock()
client.notebooks = MagicMock()
client.notebooks.get = AsyncMock()
resolver = AsyncMock(side_effect=ValueError("ambiguous"))
with pytest.raises(ValueError, match="ambiguous"):
await verify_and_set_notebook(client, "nb", json_output=False, resolve_notebook_id=resolver)
# The server is never hit when resolution fails.
client.notebooks.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_verify_and_set_notebook_propagates_get_error() -> None:
"""A ``notebooks.get`` failure (not-found/auth) propagates to the adapter."""
client = MagicMock()
client.notebooks = MagicMock()
client.notebooks.get = AsyncMock(side_effect=RuntimeError("not found"))
resolver = AsyncMock(return_value="nb_1")
with pytest.raises(RuntimeError, match="not found"):
await verify_and_set_notebook(client, "nb", json_output=False, resolve_notebook_id=resolver)
# ---------------------------------------------------------------------------
# read_status
# ---------------------------------------------------------------------------
def test_read_status_no_active_notebook() -> None:
"""No active id → ``has_context=False``; paths/env-auth still pass through."""
inputs = StatusInputs(
context_path=Path("/nonexistent/context.json"),
notebook_id=None,
path_info={"home": "/home/x"},
has_env_auth=True,
)
report = read_status(inputs)
assert isinstance(report, StatusReport)
assert report.context == StatusContext(has_context=False)
assert report.context.has_context is False
assert report.paths == {"home": "/home/x"}
assert report.has_env_auth is True
def test_read_status_reads_and_projects_context(tmp_path: Path) -> None:
"""A readable context file projects every field into the typed view."""
context_file = tmp_path / "context.json"
context_file.write_text(
'{"title": "Notes", "is_owner": true, "created_at": "2026-01-01", '
'"conversation_id": "conv_9"}',
encoding="utf-8",
)
inputs = StatusInputs(
context_path=context_file,
notebook_id="nb_active",
path_info=None,
has_env_auth=False,
)
report = read_status(inputs)
ctx = report.context
assert ctx.has_context is True
assert ctx.notebook_id == "nb_active"
assert ctx.title == "Notes"
assert ctx.is_owner is True
assert ctx.created_at == "2026-01-01"
assert ctx.conversation_id == "conv_9"
assert ctx.payload_readable is True
assert report.paths is None
def test_read_status_unreadable_payload_flags_not_readable(tmp_path: Path) -> None:
"""A corrupt/unparseable context file → has_context but payload_readable=False."""
context_file = tmp_path / "context.json"
context_file.write_text("{not valid json", encoding="utf-8")
inputs = StatusInputs(
context_path=context_file,
notebook_id="nb_active",
path_info=None,
has_env_auth=False,
)
report = read_status(inputs)
assert report.context.has_context is True
assert report.context.notebook_id == "nb_active"
assert report.context.payload_readable is False
# No payload fields are projected when the file is unreadable.
assert report.context.title is None
assert report.context.conversation_id is None
def test_read_status_non_dict_context_flags_not_readable(tmp_path: Path) -> None:
"""A context file whose JSON root is a list/scalar (not an object) is the
same failure class as corrupt JSON: payload_readable=False, no crash on
``data.get(...)`` (PR #1479 review)."""
context_file = tmp_path / "context.json"
context_file.write_text("[1, 2, 3]", encoding="utf-8")
inputs = StatusInputs(
context_path=context_file,
notebook_id="nb_active",
path_info=None,
has_env_auth=False,
)
report = read_status(inputs)
assert report.context.has_context is True
assert report.context.notebook_id == "nb_active"
assert report.context.payload_readable is False
assert report.context.title is None
def test_read_status_missing_file_with_active_id_is_unreadable(tmp_path: Path) -> None:
"""An active id but a missing context file → OSError branch (payload unreadable)."""
inputs = StatusInputs(
context_path=tmp_path / "does_not_exist.json",
notebook_id="nb_active",
path_info=None,
has_env_auth=False,
)
report = read_status(inputs)
assert report.context.has_context is True
assert report.context.payload_readable is False
def test_read_status_passes_paths_and_env_auth_through(tmp_path: Path) -> None:
"""``--paths`` info and env-auth flag survive the projection."""
context_file = tmp_path / "context.json"
context_file.write_text('{"title": "t"}', encoding="utf-8")
inputs = StatusInputs(
context_path=context_file,
notebook_id="nb_1",
path_info={"storage": "/s/state.json"},
has_env_auth=True,
)
report = read_status(inputs)
assert report.paths == {"storage": "/s/state.json"}
assert report.has_env_auth is True
# ---------------------------------------------------------------------------
# execute_logout — teardown ordering + per-step OSError mapping
# ---------------------------------------------------------------------------
def _logout_inputs(
*,
storage_path: Path,
browser_profile_dir: Path,
clear_context: Callable[[], bool],
context_path: Callable[[], Path],
env_auth_remains: bool = False,
rmtree: Callable[[Path], Any],
) -> LogoutInputs:
return LogoutInputs(
storage_path=storage_path,
browser_profile_dir=browser_profile_dir,
clear_context=clear_context,
context_path=context_path,
env_auth_remains=env_auth_remains,
rmtree=rmtree,
)
def test_logout_full_teardown_order(tmp_path: Path) -> None:
"""Storage unlink → browser rmtree → clear_context, all in order."""
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
browser_dir = tmp_path / "browser"
browser_dir.mkdir()
order: list[str] = []
def _rmtree(path: Path) -> None:
order.append(f"rmtree:{path}")
def _clear() -> bool:
order.append("clear_context")
return True
def _ctx_path() -> Path: # pragma: no cover - must NOT be called on success
raise AssertionError("context_path must stay lazy on the success path")
inputs = _logout_inputs(
storage_path=storage,
browser_profile_dir=browser_dir,
clear_context=_clear,
context_path=_ctx_path,
rmtree=_rmtree,
)
outcome = execute_logout(inputs)
assert isinstance(outcome, LogoutOutcome)
assert outcome.failure is None
assert outcome.removed_any is True
# Storage file unlinked first.
assert not storage.exists()
# Browser rmtree ran before clear_context.
assert order == [f"rmtree:{browser_dir}", "clear_context"]
def test_logout_no_artifacts_removed_any_false(tmp_path: Path) -> None:
"""Nothing on disk + clear_context returns False → removed_any stays False."""
inputs = _logout_inputs(
storage_path=tmp_path / "missing_storage.json",
browser_profile_dir=tmp_path / "missing_browser",
clear_context=lambda: False,
context_path=lambda: tmp_path / "ctx.json",
rmtree=MagicMock(),
env_auth_remains=True,
)
outcome = execute_logout(inputs)
assert outcome.removed_any is False
assert outcome.failure is None
assert outcome.env_auth_remains is True
# rmtree never runs when the browser dir is absent.
inputs.rmtree.assert_not_called() # type: ignore[attr-defined]
def test_logout_storage_unlink_oserror_short_circuits(tmp_path: Path) -> None:
"""An OSError unlinking storage → kind='storage' failure; later steps skipped."""
storage = MagicMock(spec=Path)
storage.exists.return_value = True
storage.unlink.side_effect = OSError("locked")
rmtree = MagicMock()
clear = MagicMock(return_value=True)
inputs = _logout_inputs(
storage_path=storage,
browser_profile_dir=tmp_path / "browser", # never reached (.exists not checked)
clear_context=clear,
context_path=lambda: tmp_path / "ctx.json",
rmtree=rmtree,
)
outcome = execute_logout(inputs)
assert outcome.failure is not None
assert outcome.failure.kind == "storage"
assert outcome.failure.path is storage
assert "locked" in outcome.failure.error_message
assert outcome.removed_any is False
# Pipeline short-circuits: browser + context steps never run.
rmtree.assert_not_called()
clear.assert_not_called()
def test_logout_browser_rmtree_oserror_reports_partial_storage(tmp_path: Path) -> None:
"""An OSError on rmtree after storage was removed → partial_storage_removed=True."""
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
browser_dir = tmp_path / "browser"
browser_dir.mkdir()
def _rmtree(path: Path) -> None:
raise OSError("busy")
clear = MagicMock(return_value=True)
inputs = _logout_inputs(
storage_path=storage,
browser_profile_dir=browser_dir,
clear_context=clear,
context_path=lambda: tmp_path / "ctx.json",
rmtree=_rmtree,
)
outcome = execute_logout(inputs)
assert outcome.failure is not None
assert outcome.failure.kind == "browser_profile"
assert outcome.failure.path == browser_dir
assert "busy" in outcome.failure.error_message
# Storage was already removed before the rmtree failed.
assert outcome.failure.partial_storage_removed is True
assert outcome.removed_any is True
# clear_context is skipped after the browser-step failure.
clear.assert_not_called()
def test_logout_clear_context_oserror_maps_to_context_failure(tmp_path: Path) -> None:
"""clear_context raising OSError → kind='context'; context_path resolved lazily."""
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
resolved_ctx = tmp_path / "context.json"
ctx_path_calls: list[int] = []
def _ctx_path() -> Path:
ctx_path_calls.append(1)
return resolved_ctx
def _clear() -> bool:
raise OSError("ctx locked")
inputs = _logout_inputs(
storage_path=storage,
browser_profile_dir=tmp_path / "missing_browser",
clear_context=_clear,
context_path=_ctx_path,
rmtree=MagicMock(),
)
outcome = execute_logout(inputs)
assert outcome.failure is not None
assert outcome.failure.kind == "context"
assert outcome.failure.path == resolved_ctx
assert "ctx locked" in outcome.failure.error_message
# Storage was removed before the context step failed.
assert outcome.removed_any is True
# context_path is resolved exactly once — on the failure branch only.
assert ctx_path_calls == [1]
def test_logout_context_path_is_lazy_and_does_not_abort_teardown(tmp_path: Path) -> None:
"""REGRESSION (fixed Critical): a raising ``context_path`` must NOT abort
logout before the storage/browser teardown runs.
The success path never invokes ``context_path``; even a callable that always
raises must let storage-unlink + browser-rmtree complete and return a clean
success outcome.
"""
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
browser_dir = tmp_path / "browser"
browser_dir.mkdir()
rmtree = MagicMock()
def _exploding_context_path() -> Path:
raise RuntimeError("context_path must never be called on the success path")
inputs = _logout_inputs(
storage_path=storage,
browser_profile_dir=browser_dir,
clear_context=lambda: True,
context_path=_exploding_context_path,
rmtree=rmtree,
)
# Must NOT raise — the exploding context_path is never invoked here.
outcome = execute_logout(inputs)
assert outcome.failure is None
assert outcome.removed_any is True
# Storage + browser teardown both completed despite the booby-trapped lazy path.
assert not storage.exists()
rmtree.assert_called_once_with(browser_dir)
def test_logout_failure_is_frozen_and_typed() -> None:
""":class:`LogoutFailure` carries a string error_message (hashable/redacted)."""
failure = LogoutFailure(kind="storage", path=Path("/x"), error_message="boom")
assert failure.partial_storage_removed is False
# Frozen dataclass: assignment is rejected.
with pytest.raises(FrozenInstanceError):
failure.kind = "context" # type: ignore[misc]