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
1121 lines
49 KiB
Python
1121 lines
49 KiB
Python
"""Test exception hierarchy and attributes."""
|
|
|
|
import pytest
|
|
|
|
import notebooklm
|
|
from notebooklm._env import DEFAULT_BASE_URL
|
|
from notebooklm.exceptions import (
|
|
_PREVIEW_SCRUB_CAP, # noqa: PLC2701 (test of internal)
|
|
ArtifactDownloadError,
|
|
ArtifactError,
|
|
ArtifactFeatureUnavailableError,
|
|
ArtifactInProgressTimeoutError,
|
|
ArtifactNotFoundError,
|
|
ArtifactNotReadyError,
|
|
ArtifactParseError,
|
|
ArtifactPendingTimeoutError,
|
|
ArtifactTimeoutError,
|
|
AuthError,
|
|
AuthExtractionError,
|
|
ChatError,
|
|
ClientError,
|
|
ConfigurationError,
|
|
DecodingError,
|
|
LabelError,
|
|
LabelNotFoundError,
|
|
MindMapError,
|
|
MindMapNotFoundError,
|
|
NetworkError,
|
|
NotebookError,
|
|
NotebookLimitError,
|
|
NotebookLMError,
|
|
NotebookNotFoundError,
|
|
NoteError,
|
|
NoteNotFoundError,
|
|
NotFoundError,
|
|
RateLimitError,
|
|
ResearchError,
|
|
ResearchStartUnavailableError,
|
|
ResearchTaskMismatchError,
|
|
ResearchTimeoutError,
|
|
RPCError,
|
|
RPCTimeoutError,
|
|
ServerError,
|
|
SourceAddError,
|
|
SourceError,
|
|
SourceNotFoundError,
|
|
SourceProcessingError,
|
|
SourceTimeoutError,
|
|
UnknownRPCMethodError,
|
|
ValidationError,
|
|
WaitTimeoutError,
|
|
)
|
|
from notebooklm.types import AccountLimits, GenerationStatus
|
|
|
|
|
|
class TestExceptionHierarchy:
|
|
"""Test that all exceptions inherit from NotebookLMError."""
|
|
|
|
def test_all_exceptions_inherit_from_base(self):
|
|
"""All library exceptions inherit from NotebookLMError."""
|
|
exceptions = [
|
|
ValidationError,
|
|
ConfigurationError,
|
|
NetworkError,
|
|
NotFoundError,
|
|
RPCError,
|
|
DecodingError,
|
|
UnknownRPCMethodError,
|
|
AuthError,
|
|
RateLimitError,
|
|
ServerError,
|
|
ClientError,
|
|
RPCTimeoutError,
|
|
NotebookError,
|
|
NotebookNotFoundError,
|
|
NotebookLimitError,
|
|
ChatError,
|
|
SourceError,
|
|
SourceAddError,
|
|
SourceNotFoundError,
|
|
SourceProcessingError,
|
|
SourceTimeoutError,
|
|
ArtifactError,
|
|
ArtifactNotFoundError,
|
|
ArtifactNotReadyError,
|
|
ArtifactParseError,
|
|
ArtifactDownloadError,
|
|
ArtifactFeatureUnavailableError,
|
|
ArtifactTimeoutError,
|
|
ArtifactPendingTimeoutError,
|
|
ArtifactInProgressTimeoutError,
|
|
NoteError,
|
|
NoteNotFoundError,
|
|
MindMapError,
|
|
MindMapNotFoundError,
|
|
LabelError,
|
|
LabelNotFoundError,
|
|
]
|
|
for exc_class in exceptions:
|
|
assert issubclass(exc_class, NotebookLMError), (
|
|
f"{exc_class.__name__} should inherit from NotebookLMError"
|
|
)
|
|
|
|
def test_network_error_not_under_rpc(self):
|
|
"""NetworkError is NOT under RPCError (by design)."""
|
|
assert not issubclass(NetworkError, RPCError)
|
|
assert issubclass(NetworkError, NotebookLMError)
|
|
|
|
def test_rpc_timeout_inherits_from_network_error(self):
|
|
"""RPCTimeoutError inherits from NetworkError (transport-level issue)."""
|
|
assert issubclass(RPCTimeoutError, NetworkError)
|
|
assert issubclass(RPCTimeoutError, NotebookLMError)
|
|
|
|
def test_decoding_errors_inherit_from_rpc_error(self):
|
|
"""DecodingError and UnknownRPCMethodError inherit from RPCError."""
|
|
assert issubclass(DecodingError, RPCError)
|
|
assert issubclass(UnknownRPCMethodError, DecodingError)
|
|
assert issubclass(UnknownRPCMethodError, RPCError)
|
|
|
|
def test_domain_exceptions_have_correct_base(self):
|
|
"""Domain exceptions inherit from their domain base."""
|
|
assert issubclass(NotebookNotFoundError, NotebookError)
|
|
assert issubclass(SourceAddError, SourceError)
|
|
assert issubclass(SourceNotFoundError, SourceError)
|
|
assert issubclass(SourceProcessingError, SourceError)
|
|
assert issubclass(SourceTimeoutError, SourceError)
|
|
assert issubclass(ArtifactNotFoundError, ArtifactError)
|
|
assert issubclass(ArtifactNotReadyError, ArtifactError)
|
|
assert issubclass(ArtifactParseError, ArtifactError)
|
|
assert issubclass(ArtifactDownloadError, ArtifactError)
|
|
assert issubclass(ArtifactFeatureUnavailableError, ArtifactError)
|
|
assert issubclass(ArtifactTimeoutError, ArtifactError)
|
|
assert issubclass(ArtifactTimeoutError, TimeoutError)
|
|
assert issubclass(ArtifactPendingTimeoutError, ArtifactTimeoutError)
|
|
assert issubclass(ArtifactInProgressTimeoutError, ArtifactTimeoutError)
|
|
|
|
def test_not_found_errors_are_rpc_errors(self):
|
|
"""All ``*NotFoundError`` classes mix in :class:`RPCError`.
|
|
|
|
v0.6.0 restored symmetry across the three "not found" error types so
|
|
``except RPCError`` catches all of them at transport-level call sites.
|
|
Before v0.6.0, only :class:`NotebookNotFoundError` mixed in
|
|
:class:`RPCError`; :class:`SourceNotFoundError` and
|
|
:class:`ArtifactNotFoundError` did not. This test pins the symmetry so
|
|
a regression cannot silently re-introduce the asymmetry.
|
|
"""
|
|
assert issubclass(NotebookNotFoundError, RPCError)
|
|
assert issubclass(SourceNotFoundError, RPCError)
|
|
assert issubclass(ArtifactNotFoundError, RPCError)
|
|
|
|
def test_not_found_errors_have_canonical_mro(self):
|
|
"""The MRO ordering ``(<self>, NotFoundError, RPCError, <domain-error>,
|
|
...)`` is load-bearing — it ensures the cross-domain umbrella matches
|
|
first, the RPCError-keyed catches see the ``*NotFoundError`` types
|
|
before the domain-error base does, and ``isinstance(e, RPCError)``
|
|
short-circuits via the RPC parent chain. Pinning the order guards
|
|
against accidentally swapping bases in a future refactor.
|
|
"""
|
|
assert NotebookNotFoundError.__mro__[1:4] == (NotFoundError, RPCError, NotebookError)
|
|
assert SourceNotFoundError.__mro__[1:4] == (NotFoundError, RPCError, SourceError)
|
|
assert ArtifactNotFoundError.__mro__[1:4] == (NotFoundError, RPCError, ArtifactError)
|
|
assert NoteNotFoundError.__mro__[1:4] == (NotFoundError, RPCError, NoteError)
|
|
assert MindMapNotFoundError.__mro__[1:4] == (NotFoundError, RPCError, MindMapError)
|
|
|
|
def test_not_found_errors_caught_by_except_rpc_error(self):
|
|
"""End-to-end ``try/except RPCError`` exercise — a direct regression
|
|
pin for the stated v0.6.0 contract that ``except RPCError`` catches
|
|
each of the three "not found" types. ``issubclass`` is sufficient for
|
|
MRO inspection, but a real ``raise``/``except`` round-trip exercises
|
|
the runtime catch path and is what the BREAKING CHANGE migration
|
|
guidance actually promises users."""
|
|
with pytest.raises(RPCError):
|
|
raise NotebookNotFoundError("nb_x")
|
|
with pytest.raises(RPCError):
|
|
raise SourceNotFoundError("src_x")
|
|
with pytest.raises(RPCError):
|
|
raise ArtifactNotFoundError("art_x")
|
|
|
|
def test_source_not_found_is_rpc_error(self):
|
|
"""``SourceNotFoundError`` is catchable as ``RPCError`` (v0.6.0).
|
|
|
|
Restores symmetry with :class:`NotebookNotFoundError` (which has
|
|
inherited from :class:`RPCError` since the 0.5.x series). This is a
|
|
v0.6.0 BREAKING CHANGE for code that catches ``RPCError`` before
|
|
``SourceNotFoundError``.
|
|
"""
|
|
assert issubclass(SourceNotFoundError, RPCError)
|
|
err = SourceNotFoundError("src_x", method_id="rwIQyf")
|
|
assert err.source_id == "src_x"
|
|
assert err.method_id == "rwIQyf"
|
|
# Catchable as both RPCError and SourceError.
|
|
assert isinstance(err, RPCError)
|
|
assert isinstance(err, SourceError)
|
|
|
|
def test_artifact_not_found_is_rpc_error(self):
|
|
"""``ArtifactNotFoundError`` is catchable as ``RPCError`` (v0.6.0).
|
|
|
|
Restores symmetry with :class:`NotebookNotFoundError`. This is a
|
|
v0.6.0 BREAKING CHANGE for code that catches ``RPCError`` before
|
|
``ArtifactNotFoundError``.
|
|
"""
|
|
assert issubclass(ArtifactNotFoundError, RPCError)
|
|
err = ArtifactNotFoundError("art_x", artifact_type="audio", method_id="abc")
|
|
assert err.artifact_id == "art_x"
|
|
assert err.artifact_type == "audio"
|
|
assert err.method_id == "abc"
|
|
# Catchable as both RPCError and ArtifactError.
|
|
assert isinstance(err, RPCError)
|
|
assert isinstance(err, ArtifactError)
|
|
|
|
def test_notebook_limit_error_is_exported_from_package(self):
|
|
"""NotebookLimitError is available from the public package namespace."""
|
|
assert notebooklm.NotebookLimitError is NotebookLimitError
|
|
assert "NotebookLimitError" in notebooklm.__all__
|
|
|
|
def test_artifact_timeout_errors_are_exported_from_package(self):
|
|
"""Structured artifact timeout errors are public API exceptions."""
|
|
assert notebooklm.ArtifactTimeoutError is ArtifactTimeoutError
|
|
assert notebooklm.ArtifactPendingTimeoutError is ArtifactPendingTimeoutError
|
|
assert notebooklm.ArtifactInProgressTimeoutError is ArtifactInProgressTimeoutError
|
|
assert "ArtifactTimeoutError" in notebooklm.__all__
|
|
assert "ArtifactPendingTimeoutError" in notebooklm.__all__
|
|
assert "ArtifactInProgressTimeoutError" in notebooklm.__all__
|
|
|
|
def test_artifact_timeout_accepts_sequence_history(self):
|
|
"""Manual exception construction normalizes status history to a tuple."""
|
|
err = ArtifactTimeoutError(
|
|
"nb_123",
|
|
"task_123",
|
|
30.0,
|
|
last_status="in_progress",
|
|
status_history=["pending", "in_progress"],
|
|
)
|
|
|
|
assert err.status_history == ("pending", "in_progress")
|
|
assert "notebook nb_123" in str(err)
|
|
assert "pending -> in_progress" in str(err)
|
|
|
|
def test_artifact_timeout_accepts_sequence_transitions(self):
|
|
"""Manual exception construction normalizes status snapshots to a tuple."""
|
|
transitions = [
|
|
GenerationStatus(task_id="task_123", status="pending"),
|
|
GenerationStatus(task_id="task_123", status="in_progress"),
|
|
]
|
|
err = ArtifactTimeoutError("nb_123", "task_123", 30.0, status_transitions=transitions)
|
|
|
|
assert err.status_transitions == tuple(transitions)
|
|
assert err.status_history == ("pending", "in_progress")
|
|
assert "pending -> in_progress" in str(err)
|
|
|
|
def test_artifact_pending_timeout_without_history_reports_no_status(self):
|
|
"""The defensive no-history message branch is part of the public repr."""
|
|
err = ArtifactPendingTimeoutError("nb_123", "task_123", 30.0)
|
|
|
|
assert err.status_history == ()
|
|
assert err.status_transitions == ()
|
|
assert err.stalled_phase == "pending"
|
|
assert "no status" in str(err)
|
|
|
|
def test_account_types_are_exported_from_package(self):
|
|
"""Account limit types are available from the public package namespace."""
|
|
assert notebooklm.AccountLimits is AccountLimits
|
|
assert "AccountLimits" in notebooklm.__all__
|
|
|
|
|
|
class TestNotFoundErrorUmbrella:
|
|
"""The NotFoundError umbrella catches every *NotFoundError across domains.
|
|
|
|
Catch semantics for the existing per-type bases (NotebookError /
|
|
SourceError / ArtifactError) MUST remain unchanged — adding the umbrella
|
|
itself was purely additive in the original PR #1035. v0.6.0 then
|
|
restored RPCError symmetry across all three concrete subclasses (see
|
|
``TestExceptionHierarchy.test_not_found_errors_are_rpc_errors`` and
|
|
``TestDomainExceptions.test_source_not_found_is_rpc_error`` /
|
|
``test_artifact_not_found_is_rpc_error``); the umbrella class itself
|
|
deliberately stays OUT of the RPCError subtree (see
|
|
``test_not_found_error_itself_is_not_an_rpc_error``).
|
|
"""
|
|
|
|
def test_not_found_error_is_subclass_of_notebooklm_error(self):
|
|
"""NotFoundError lives under the top-level NotebookLMError umbrella."""
|
|
assert issubclass(NotFoundError, NotebookLMError)
|
|
|
|
def test_not_found_error_itself_is_not_an_rpc_error(self):
|
|
"""The umbrella class itself must NOT inherit from RPCError.
|
|
|
|
The umbrella sits next to (not under) :class:`RPCError` in the
|
|
hierarchy — it catches "missing resource" regardless of whether the
|
|
underlying signal was an RPC degenerate-payload (the three concrete
|
|
subclasses also mix in :class:`RPCError` as of v0.6.0) or a future
|
|
non-RPC source of missing-resource signals. Pinning ``RPCError not
|
|
in NotFoundError.__mro__`` guards against accidentally collapsing
|
|
the umbrella into the RPC subtree.
|
|
"""
|
|
assert not issubclass(NotFoundError, RPCError)
|
|
assert RPCError not in NotFoundError.__mro__
|
|
|
|
def test_not_found_error_catches_notebook_not_found(self):
|
|
"""`except NotFoundError` catches NotebookNotFoundError."""
|
|
assert issubclass(NotebookNotFoundError, NotFoundError)
|
|
with pytest.raises(NotFoundError):
|
|
raise NotebookNotFoundError("nb-123")
|
|
|
|
def test_not_found_error_catches_source_not_found(self):
|
|
"""`except NotFoundError` catches SourceNotFoundError."""
|
|
assert issubclass(SourceNotFoundError, NotFoundError)
|
|
with pytest.raises(NotFoundError):
|
|
raise SourceNotFoundError("src-123")
|
|
|
|
def test_not_found_error_catches_artifact_not_found(self):
|
|
"""`except NotFoundError` catches ArtifactNotFoundError."""
|
|
assert issubclass(ArtifactNotFoundError, NotFoundError)
|
|
with pytest.raises(NotFoundError):
|
|
raise ArtifactNotFoundError("art-123", "audio")
|
|
|
|
def test_not_found_error_catches_note_not_found(self):
|
|
"""`except NotFoundError` catches NoteNotFoundError."""
|
|
assert issubclass(NoteNotFoundError, NotFoundError)
|
|
with pytest.raises(NotFoundError):
|
|
raise NoteNotFoundError("note-123")
|
|
|
|
def test_not_found_error_catches_mind_map_not_found(self):
|
|
"""`except NotFoundError` catches MindMapNotFoundError."""
|
|
assert issubclass(MindMapNotFoundError, NotFoundError)
|
|
with pytest.raises(NotFoundError):
|
|
raise MindMapNotFoundError("mm-123")
|
|
|
|
def test_existing_catches_still_work(self):
|
|
"""Adding NotFoundError must not break existing domain catches.
|
|
|
|
Regression guard: each *NotFoundError must still be caught by its
|
|
legacy domain base(s).
|
|
"""
|
|
# Notebook side: still RPCError + NotebookError.
|
|
with pytest.raises(NotebookError):
|
|
raise NotebookNotFoundError("nb-1")
|
|
with pytest.raises(RPCError):
|
|
raise NotebookNotFoundError("nb-2")
|
|
|
|
# Source side: still SourceError.
|
|
with pytest.raises(SourceError):
|
|
raise SourceNotFoundError("src-1")
|
|
|
|
# Artifact side: still ArtifactError.
|
|
with pytest.raises(ArtifactError):
|
|
raise ArtifactNotFoundError("art-1", "audio")
|
|
|
|
# Note: prior tests `test_source_not_found_does_not_gain_rpc_error` and
|
|
# `test_artifact_not_found_does_not_gain_rpc_error` (added with the
|
|
# NotFoundError umbrella) explicitly pinned the asymmetry where only
|
|
# NotebookNotFoundError inherits from RPCError. v0.6.0 deliberately
|
|
# widened the symmetry — see ``TestExceptionHierarchy.
|
|
# test_not_found_errors_are_rpc_errors`` and
|
|
# ``test_not_found_errors_have_canonical_mro`` (positive-assertion
|
|
# replacements) plus the ``TestDomainExceptions.
|
|
# test_source_not_found_is_rpc_error`` /
|
|
# ``test_artifact_not_found_is_rpc_error`` instance-level proofs.
|
|
|
|
def test_not_found_error_is_exported_from_package(self):
|
|
"""NotFoundError is reachable via ``from notebooklm import NotFoundError``."""
|
|
assert notebooklm.NotFoundError is NotFoundError
|
|
assert "NotFoundError" in notebooklm.__all__
|
|
|
|
def test_not_found_error_catches_all_three_in_one_clause(self):
|
|
"""The motivating use case: one `except NotFoundError` clause
|
|
replaces a 3-tuple ``except (NotebookNotFoundError, SourceNotFoundError,
|
|
ArtifactNotFoundError):``."""
|
|
caught: list[type] = []
|
|
for exc in (
|
|
NotebookNotFoundError("nb"),
|
|
SourceNotFoundError("src"),
|
|
ArtifactNotFoundError("art", "audio"),
|
|
):
|
|
try:
|
|
raise exc
|
|
except NotFoundError as e:
|
|
caught.append(type(e))
|
|
assert caught == [
|
|
NotebookNotFoundError,
|
|
SourceNotFoundError,
|
|
ArtifactNotFoundError,
|
|
]
|
|
|
|
|
|
class TestWaitTimeoutErrorUmbrella:
|
|
"""The WaitTimeoutError umbrella catches every wait/poll timeout.
|
|
|
|
Added in v0.7.0 (issue #1208). It is purely additive: it mixes in the
|
|
built-in :class:`TimeoutError`, so existing ``except TimeoutError`` clauses
|
|
keep catching every wait timeout, and it widens the inheritance of the
|
|
source / artifact / research timeout types without disturbing their
|
|
domain bases.
|
|
"""
|
|
|
|
def test_umbrella_inherits_timeout_and_base(self):
|
|
assert issubclass(WaitTimeoutError, TimeoutError)
|
|
assert issubclass(WaitTimeoutError, NotebookLMError)
|
|
|
|
def test_all_wait_timeouts_subclass_umbrella(self):
|
|
for exc_class in (
|
|
SourceTimeoutError,
|
|
ArtifactTimeoutError,
|
|
ArtifactPendingTimeoutError,
|
|
ArtifactInProgressTimeoutError,
|
|
ResearchTimeoutError,
|
|
):
|
|
assert issubclass(exc_class, WaitTimeoutError), exc_class.__name__
|
|
# Still a built-in TimeoutError (backward-compatible catchability).
|
|
assert issubclass(exc_class, TimeoutError), exc_class.__name__
|
|
|
|
def test_domain_bases_unchanged(self):
|
|
"""Widening to WaitTimeoutError must not disturb the domain bases."""
|
|
assert issubclass(SourceTimeoutError, SourceError)
|
|
assert issubclass(ArtifactTimeoutError, ArtifactError)
|
|
assert issubclass(ResearchTimeoutError, ResearchError)
|
|
assert issubclass(ResearchError, NotebookLMError)
|
|
|
|
def test_wait_timeouts_declare_umbrella_base_first(self):
|
|
"""The ``*TimeoutError`` types list ``WaitTimeoutError`` before their
|
|
domain base, so the wait-timeout umbrella reads consistently across
|
|
domains. ``ArtifactTimeoutError`` was the lone outlier
|
|
(``(ArtifactError, WaitTimeoutError)``) and is now umbrella-first like
|
|
its siblings. The reorder is cosmetic — ``isinstance`` against either
|
|
base is unaffected (proven by ``test_umbrella_catches_source_artifact_research``
|
|
and ``test_domain_bases_unchanged``).
|
|
"""
|
|
assert SourceTimeoutError.__bases__ == (WaitTimeoutError, SourceError)
|
|
assert ArtifactTimeoutError.__bases__ == (WaitTimeoutError, ArtifactError)
|
|
assert ResearchTimeoutError.__bases__ == (WaitTimeoutError, ResearchError)
|
|
|
|
def test_umbrella_catches_source_artifact_research(self):
|
|
"""One ``except WaitTimeoutError`` clause catches all three domains."""
|
|
caught: list[type] = []
|
|
for exc in (
|
|
SourceTimeoutError("src-1", 12.0),
|
|
ArtifactTimeoutError("nb-1", "task-1", 30.0),
|
|
ResearchTimeoutError("nb-1", "task-1", 60.0),
|
|
):
|
|
try:
|
|
raise exc
|
|
except WaitTimeoutError as e:
|
|
caught.append(type(e))
|
|
assert caught == [
|
|
SourceTimeoutError,
|
|
ArtifactTimeoutError,
|
|
ResearchTimeoutError,
|
|
]
|
|
|
|
def test_builtin_timeout_error_still_catches_all(self):
|
|
"""Backward compatibility: ``except TimeoutError`` still works."""
|
|
for exc in (
|
|
SourceTimeoutError("src-1", 12.0),
|
|
ArtifactTimeoutError("nb-1", "task-1", 30.0),
|
|
ResearchTimeoutError("nb-1", "task-1", 60.0),
|
|
):
|
|
with pytest.raises(TimeoutError):
|
|
raise exc
|
|
|
|
def test_research_timeout_attributes(self):
|
|
err = ResearchTimeoutError("nb-1", "task-7", 60.0, last_status="in_progress")
|
|
assert err.notebook_id == "nb-1"
|
|
assert err.task_id == "task-7"
|
|
assert err.timeout == 60.0
|
|
assert err.timeout_seconds == 60.0
|
|
assert err.last_status == "in_progress"
|
|
assert "task-7" in str(err)
|
|
assert "in_progress" in str(err)
|
|
|
|
def test_research_task_mismatch_stays_validation_error(self):
|
|
"""ResearchTaskMismatchError stays a ValidationError, not ResearchError.
|
|
|
|
It is a caller-input validation failure on ``import_sources``, so it
|
|
keeps its :class:`ValidationError` base and is deliberately NOT moved
|
|
under the new :class:`ResearchError` domain base.
|
|
"""
|
|
assert issubclass(ResearchTaskMismatchError, ValidationError)
|
|
assert not issubclass(ResearchTaskMismatchError, ResearchError)
|
|
assert not issubclass(ResearchTaskMismatchError, WaitTimeoutError)
|
|
|
|
def test_research_start_unavailable_attributes_and_catchability(self):
|
|
err = ResearchStartUnavailableError(
|
|
"nb-1",
|
|
"deep",
|
|
method_id="QA9ei",
|
|
found_ids=["QA9ei"],
|
|
)
|
|
|
|
assert issubclass(ResearchStartUnavailableError, RPCError)
|
|
assert issubclass(ResearchStartUnavailableError, ResearchError)
|
|
assert ResearchStartUnavailableError.__bases__ == (RPCError, ResearchError)
|
|
assert err.notebook_id == "nb-1"
|
|
assert err.mode == "deep"
|
|
assert err.method_id == "QA9ei"
|
|
assert err.found_ids == ["QA9ei"]
|
|
assert "Deep research failed to start" in str(err)
|
|
assert "QA9ei" not in str(err)
|
|
assert "Found IDs" not in str(err)
|
|
|
|
def test_umbrella_and_research_exports(self):
|
|
assert notebooklm.WaitTimeoutError is WaitTimeoutError
|
|
assert notebooklm.ResearchError is ResearchError
|
|
assert notebooklm.ResearchStartUnavailableError is ResearchStartUnavailableError
|
|
assert notebooklm.ResearchTimeoutError is ResearchTimeoutError
|
|
assert "WaitTimeoutError" in notebooklm.__all__
|
|
assert "ResearchError" in notebooklm.__all__
|
|
assert "ResearchStartUnavailableError" in notebooklm.__all__
|
|
assert "ResearchTimeoutError" in notebooklm.__all__
|
|
|
|
|
|
class TestNoteAndMindMapNotFound:
|
|
"""The note / mind-map not-found exceptions mirror ``SourceNotFoundError``.
|
|
|
|
Added as the prerequisite for the mind-map not-found work (#1291), these
|
|
are the first members of the note and mind-map domain subtrees. Each is
|
|
a triple-base ``(NotFoundError, RPCError, <Domain>Error)`` so it is
|
|
catchable via the cross-domain umbrella, at transport-level call sites,
|
|
and at domain-level call sites — exactly like ``SourceNotFoundError``.
|
|
"""
|
|
|
|
def test_note_not_found_attributes_and_catchability(self):
|
|
err = NoteNotFoundError("note-x", method_id="abc")
|
|
assert err.note_id == "note-x"
|
|
assert err.method_id == "abc"
|
|
assert "note-x" in str(err)
|
|
# Catchable as the umbrella, the RPC layer, and the domain base.
|
|
assert isinstance(err, NotFoundError)
|
|
assert isinstance(err, RPCError)
|
|
assert isinstance(err, NoteError)
|
|
|
|
def test_note_not_found_raw_response_kwarg(self):
|
|
err = NoteNotFoundError("note-x", raw_response="payload")
|
|
assert err.raw_response == "payload"
|
|
|
|
def test_mind_map_not_found_attributes_and_catchability(self):
|
|
err = MindMapNotFoundError("mm-x", method_id="abc")
|
|
assert err.mind_map_id == "mm-x"
|
|
assert err.method_id == "abc"
|
|
assert "mm-x" in str(err)
|
|
# Catchable as the umbrella, the RPC layer, and the domain base.
|
|
assert isinstance(err, NotFoundError)
|
|
assert isinstance(err, RPCError)
|
|
assert isinstance(err, MindMapError)
|
|
|
|
def test_mind_map_not_found_raw_response_kwarg(self):
|
|
err = MindMapNotFoundError("mm-x", raw_response="payload")
|
|
assert err.raw_response == "payload"
|
|
|
|
def test_domain_bases_are_under_notebooklm_error(self):
|
|
assert issubclass(NoteError, NotebookLMError)
|
|
assert issubclass(MindMapError, NotebookLMError)
|
|
# The bare domain bases are NOT not-found / RPC types.
|
|
assert not issubclass(NoteError, NotFoundError)
|
|
assert not issubclass(MindMapError, RPCError)
|
|
|
|
def test_exported_from_package(self):
|
|
for name, obj in (
|
|
("NoteError", NoteError),
|
|
("NoteNotFoundError", NoteNotFoundError),
|
|
("MindMapError", MindMapError),
|
|
("MindMapNotFoundError", MindMapNotFoundError),
|
|
):
|
|
assert getattr(notebooklm, name) is obj
|
|
assert name in notebooklm.__all__
|
|
|
|
|
|
class TestRPCErrorAttributes:
|
|
"""Test RPCError attribute handling."""
|
|
|
|
def test_rpc_error_stores_method_id(self):
|
|
"""RPCError stores method_id attribute."""
|
|
e = RPCError("Failed", method_id="abc123")
|
|
assert e.method_id == "abc123"
|
|
|
|
def test_rpc_error_backward_compat_rpc_id(self):
|
|
"""RPCError supports permanent backward-compatible rpc_id alias without warning."""
|
|
import warnings
|
|
|
|
e = RPCError("Failed", method_id="abc123")
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
assert e.rpc_id == "abc123" # Alias
|
|
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
|
assert deprecation_warnings == []
|
|
|
|
def test_rpc_error_stores_rpc_code(self):
|
|
"""RPCError stores rpc_code attribute."""
|
|
e = RPCError("Failed", rpc_code=404)
|
|
assert e.rpc_code == 404
|
|
|
|
def test_rpc_error_backward_compat_code(self):
|
|
"""RPCError supports permanent backward-compatible code alias without warning."""
|
|
import warnings
|
|
|
|
e = RPCError("Failed", rpc_code="NOT_FOUND")
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
assert e.code == "NOT_FOUND" # Alias
|
|
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
|
assert deprecation_warnings == []
|
|
|
|
def test_rpc_error_truncates_raw_response(self, monkeypatch):
|
|
"""RPCError truncates raw_response to 80 chars + '...' by default."""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
long_response = "x" * 1000
|
|
e = RPCError("Failed", raw_response=long_response)
|
|
assert e.raw_response is not None
|
|
assert len(e.raw_response) == 83
|
|
assert e.raw_response.endswith("...")
|
|
assert e.raw_response[:-3] == "x" * 80
|
|
|
|
def test_rpc_error_scrubs_secrets_in_raw_response(self, monkeypatch):
|
|
"""raw_response is secret-scrubbed before it can leak.
|
|
|
|
``raw_response`` is a public attribute that escapes the logging
|
|
pipeline's ``RedactingFilter`` — it is spliced into error ``str``/repr
|
|
and survives serialization. Credential-shaped substrings must therefore
|
|
be redacted at the source. The default (truncated) path keeps the scrub
|
|
*before* the 80-char cut so a secret sitting in the first 80 chars can
|
|
never survive into the preview.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
secret = "AF1_QpN-supersecretcsrftoken1234567890abcdefghij"
|
|
raw = f'{{"SNlM0e":"{secret}"}}'
|
|
# Realistic splice: callers build the message from the (now scrubbed)
|
|
# raw_response attribute, which is the surface str(exc) exposes.
|
|
e = RPCError("decode failed", raw_response=raw)
|
|
assert e.raw_response is not None
|
|
assert secret not in e.raw_response
|
|
assert "***" in e.raw_response
|
|
# A message built from the scrubbed preview stays clean too.
|
|
spliced = RPCError(f"decode failed: {e.raw_response}", raw_response=raw)
|
|
assert secret not in str(spliced)
|
|
|
|
def test_rpc_error_scrubs_secrets_in_debug_full_body(self, monkeypatch):
|
|
"""NOTEBOOKLM_DEBUG=1 keeps the full body but still scrubs secrets.
|
|
|
|
The deep-debug branch returns the untruncated body; it must scrub THEN
|
|
return so the full-body opt-in does not become a token leak.
|
|
"""
|
|
monkeypatch.setenv("NOTEBOOKLM_DEBUG", "1")
|
|
secret = "AF1_QpN-supersecretcsrftoken1234567890abcdefghij"
|
|
raw = f'{{"SNlM0e":"{secret}"}}' + "x" * 1000
|
|
e = RPCError("decode failed", raw_response=raw)
|
|
assert e.raw_response is not None
|
|
# Full body preserved (not truncated) but the token is gone.
|
|
assert len(e.raw_response) > 80
|
|
assert not e.raw_response.endswith("...")
|
|
assert secret not in e.raw_response
|
|
assert "***" in e.raw_response
|
|
spliced = RPCError(f"decode failed: {e.raw_response}", raw_response=raw)
|
|
assert secret not in str(spliced)
|
|
|
|
def test_rpc_error_scrubs_secret_straddling_truncation_boundary(self, monkeypatch):
|
|
"""A secret straddling the 80-char preview cut is scrubbed, not halved.
|
|
|
|
Scrubbing runs on the pre-sliced window *before* the 80-char cut, so a
|
|
token positioned so that it spans the boundary is neutralized whole — no
|
|
partial-token suffix can leak into the truncated preview. This is the
|
|
property the pre-slice (scrub-then-cut) ordering exists to preserve.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
secret = "AF1_QpN-supersecretcsrftoken1234567890abcdefghij"
|
|
# Pad so the secret suffix straddles the 80-char cut.
|
|
raw = "x" * 70 + secret
|
|
e = RPCError("decode failed", raw_response=raw)
|
|
assert e.raw_response is not None
|
|
assert e.raw_response.endswith("...")
|
|
# The secret suffix is gone — only the ``AF1_QpN-`` shape hint (a
|
|
# deliberate non-secret marker) and a ``*`` redaction stub remain.
|
|
assert "supersecret" not in e.raw_response
|
|
assert "csrftoken" not in e.raw_response
|
|
assert "*" in e.raw_response
|
|
|
|
def test_rpc_error_preview_drops_secret_beyond_scrub_cap(self, monkeypatch):
|
|
"""A secret past the pre-slice cap is dropped, never partially leaked.
|
|
|
|
The truncated path only scrubs the first ``_PREVIEW_SCRUB_CAP`` chars,
|
|
but anything beyond the cap is also beyond the 80-char preview, so it is
|
|
discarded rather than exposed. This guards the perf optimization against
|
|
ever shrinking the redaction surface that reaches the preview.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
secret = "AF1_QpN-supersecretcsrftoken1234567890abcdefghij"
|
|
raw = "x" * (_PREVIEW_SCRUB_CAP + 50) + secret
|
|
e = RPCError("decode failed", raw_response=raw)
|
|
assert e.raw_response is not None
|
|
assert secret not in e.raw_response
|
|
assert e.raw_response == "x" * 80 + "..."
|
|
|
|
def test_unknown_rpc_method_error_scrubs_secrets_in_raw_response(self, monkeypatch):
|
|
"""UnknownRPCMethodError forwards string raw_response through the scrub.
|
|
|
|
It splices structured context into ``str``/``repr`` and exposes the
|
|
public ``raw_response`` attribute, so the same redaction guarantee must
|
|
hold for the subclass that carries the string branch.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
secret = "AF1_QpN-supersecretcsrftoken1234567890abcdefghij"
|
|
raw = f'{{"SNlM0e":"{secret}"}}'
|
|
e = UnknownRPCMethodError(
|
|
"schema drift",
|
|
method_id="abc123",
|
|
raw_response=raw,
|
|
)
|
|
assert isinstance(e.raw_response, str)
|
|
assert secret not in e.raw_response
|
|
assert "***" in e.raw_response
|
|
# str/repr that splice the scrubbed attribute never leak the token.
|
|
spliced = UnknownRPCMethodError(
|
|
f"schema drift: {e.raw_response}",
|
|
method_id="abc123",
|
|
raw_response=raw,
|
|
)
|
|
assert secret not in str(spliced)
|
|
assert secret not in repr(spliced)
|
|
|
|
def test_unknown_rpc_method_error_scrubs_secrets_in_data_at_failure(self, monkeypatch):
|
|
"""``data_at_failure`` is scrubbed at store time so str()/repr() are safe.
|
|
|
|
Red-first: ``data_at_failure`` was spliced verbatim with ``!r`` into
|
|
``__str__`` / ``__repr__`` / tracebacks (a string splice that bypasses
|
|
the logging ``RedactingFilter``), unlike the sibling ``raw_response``
|
|
which was already scrubbed. A credential-shaped value therefore leaked
|
|
through every rendering regardless of ``NOTEBOOKLM_DEBUG`` (#1518).
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
secret = "AF1_QpN-supersecretcsrftoken1234567890abcdefghij"
|
|
e = UnknownRPCMethodError(
|
|
"safe_index drift",
|
|
method_id="abc123",
|
|
data_at_failure=repr({"SNlM0e": secret}),
|
|
)
|
|
# Scrubbed at STORE time: the attribute itself carries no secret.
|
|
assert secret not in str(e.data_at_failure)
|
|
assert "***" in str(e.data_at_failure)
|
|
# And both render paths that splice it stay clean.
|
|
assert secret not in str(e)
|
|
assert secret not in repr(e)
|
|
|
|
def test_unknown_rpc_method_error_data_at_failure_token_shape_scrubbed(self, monkeypatch):
|
|
"""A token-shaped value under an unknown carrier is scrubbed in str()/repr().
|
|
|
|
Defense in depth: even a raw ``g.a000-`` SID token embedded in the
|
|
indexed data (no recognizable cookie/key name around it) is neutralized
|
|
by the shared ``scrub_secrets`` catch-all before it reaches any surface.
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
token = "g.a000-leakytokenburiedindata"
|
|
e = UnknownRPCMethodError(
|
|
"safe_index drift",
|
|
method_id="x",
|
|
data_at_failure=repr(["unrelated", token, 42]),
|
|
)
|
|
assert token not in str(e)
|
|
assert token not in repr(e)
|
|
assert e.data_at_failure is not None and "g.a000-" not in e.data_at_failure
|
|
|
|
def test_unknown_rpc_method_error_data_at_failure_api_key_scrubbed(self, monkeypatch):
|
|
"""A Google API key (``AIza…``) in data_at_failure is scrubbed (codex #1517).
|
|
|
|
Red-first: the API-key shape was missing from the runtime catch-alls, so
|
|
``UnknownRPCMethodError(data_at_failure=repr({"JrWMbf": api_key}))`` —
|
|
the WIZ_global_data key that the cassette registry already treats as
|
|
must-scrub — leaked the key verbatim through str()/repr().
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
api_key = "AIza" + "B" * 35
|
|
e = UnknownRPCMethodError(
|
|
"safe_index drift",
|
|
method_id="x",
|
|
data_at_failure=repr({"JrWMbf": api_key}),
|
|
)
|
|
assert api_key not in str(e)
|
|
assert api_key not in repr(e)
|
|
assert e.data_at_failure is not None and "AIza" not in e.data_at_failure
|
|
|
|
def test_unknown_rpc_method_error_data_at_failure_dotted_secure_cookie_scrubbed(
|
|
self, monkeypatch
|
|
):
|
|
"""A ``__Secure-NEW.SESSION=…`` pair in data_at_failure is scrubbed.
|
|
|
|
Red-first: a too-narrow umbrella NAME charset leaks RFC 6265
|
|
``token``-set secure/host cookie names containing ``.`` (codex re-review
|
|
of #1517). The value rides into ``data_at_failure`` (e.g. a captured
|
|
Set-Cookie line) and must not survive str()/repr().
|
|
"""
|
|
monkeypatch.delenv("NOTEBOOKLM_DEBUG", raising=False)
|
|
secret = "opaqueDottedSecureCookieValueXYZ"
|
|
# No ``Set-Cookie:`` prefix — exercises the umbrella directly, not the
|
|
# whole-jar ``Set-Cookie:`` header pattern.
|
|
e = UnknownRPCMethodError(
|
|
"safe_index drift",
|
|
method_id="x",
|
|
data_at_failure=repr({"cookie": f"__Secure-NEW.SESSION={secret}"}),
|
|
)
|
|
assert secret not in str(e)
|
|
assert secret not in repr(e)
|
|
assert e.data_at_failure is not None and secret not in e.data_at_failure
|
|
|
|
def test_rpc_error_stores_found_ids(self):
|
|
"""RPCError stores found_ids list."""
|
|
e = RPCError("Failed", found_ids=["id1", "id2"])
|
|
assert e.found_ids == ["id1", "id2"]
|
|
|
|
def test_rpc_error_found_ids_defaults_to_empty(self):
|
|
"""RPCError found_ids defaults to empty list."""
|
|
e = RPCError("Failed")
|
|
assert e.found_ids == []
|
|
|
|
|
|
class TestRateLimitError:
|
|
"""Test RateLimitError-specific attributes."""
|
|
|
|
def test_rate_limit_error_has_retry_after(self):
|
|
"""RateLimitError stores retry_after attribute."""
|
|
e = RateLimitError("Too fast", retry_after=30)
|
|
assert e.retry_after == 30
|
|
assert "Too fast" in str(e)
|
|
|
|
def test_rate_limit_error_retry_after_optional(self):
|
|
"""RateLimitError retry_after is optional."""
|
|
e = RateLimitError("Too fast")
|
|
assert e.retry_after is None
|
|
|
|
|
|
class TestServerError:
|
|
"""Test ServerError-specific attributes."""
|
|
|
|
def test_server_error_has_status_code(self):
|
|
"""ServerError stores status_code attribute."""
|
|
e = ServerError("Internal error", status_code=500)
|
|
assert e.status_code == 500
|
|
|
|
|
|
class TestClientError:
|
|
"""Test ClientError-specific attributes."""
|
|
|
|
def test_client_error_has_status_code(self):
|
|
"""ClientError stores status_code attribute."""
|
|
e = ClientError("Bad request", status_code=400)
|
|
assert e.status_code == 400
|
|
|
|
|
|
class TestNetworkError:
|
|
"""Test NetworkError-specific attributes."""
|
|
|
|
def test_network_error_stores_original_error(self):
|
|
"""NetworkError stores original_error attribute."""
|
|
original = ConnectionError("Connection refused")
|
|
e = NetworkError("Failed to connect", original_error=original)
|
|
assert e.original_error is original
|
|
|
|
def test_network_error_stores_method_id(self):
|
|
"""NetworkError stores method_id attribute."""
|
|
e = NetworkError("Failed", method_id="abc123")
|
|
assert e.method_id == "abc123"
|
|
|
|
|
|
class TestRPCTimeoutError:
|
|
"""Test RPCTimeoutError-specific attributes."""
|
|
|
|
def test_timeout_error_has_timeout_seconds(self):
|
|
"""RPCTimeoutError stores timeout_seconds attribute."""
|
|
e = RPCTimeoutError("Timed out", timeout_seconds=30.0)
|
|
assert e.timeout_seconds == 30.0
|
|
|
|
|
|
class TestDomainExceptions:
|
|
"""Test domain-specific exception attributes."""
|
|
|
|
def test_notebook_not_found_has_notebook_id(self):
|
|
"""NotebookNotFoundError stores notebook_id."""
|
|
e = NotebookNotFoundError("nb_123")
|
|
assert e.notebook_id == "nb_123"
|
|
assert "nb_123" in str(e)
|
|
|
|
def test_notebook_limit_error_has_count_and_limit(self):
|
|
"""NotebookLimitError stores quota context."""
|
|
original = RPCError("create failed", method_id="CCqFvf", rpc_code=3)
|
|
e = NotebookLimitError(499, limit=500, original_error=original)
|
|
|
|
assert e.current_count == 499
|
|
assert e.limit == 500
|
|
assert e.known_limits == ()
|
|
assert e.original_error is original
|
|
assert "499/500" in str(e)
|
|
assert "notebook limit" in str(e).lower()
|
|
|
|
def test_notebook_limit_error_json_extra_includes_original_rpc_context(self):
|
|
"""NotebookLimitError exposes structured JSON metadata."""
|
|
original = RPCError("create failed", method_id="CCqFvf", rpc_code=3)
|
|
e = NotebookLimitError(499, limit=500, original_error=original)
|
|
|
|
assert e.to_error_response_extra() == {
|
|
"current_count": 499,
|
|
"limit": 500,
|
|
"method_id": "CCqFvf",
|
|
"rpc_code": 3,
|
|
}
|
|
|
|
def test_notebook_limit_error_handles_empty_known_limits(self):
|
|
"""NotebookLimitError omits known-limit sentence when none are provided."""
|
|
e = NotebookLimitError(499, limit=500, known_limits=())
|
|
|
|
assert e.known_limits == ()
|
|
assert "Known NotebookLM limits include" not in str(e)
|
|
|
|
def test_notebook_limit_error_preserves_explicit_known_limits(self):
|
|
"""NotebookLimitError keeps explicit known limits for compatibility."""
|
|
e = NotebookLimitError(499, limit=500, known_limits=(100, 500))
|
|
|
|
assert e.known_limits == (100, 500)
|
|
assert "Known NotebookLM limits include: 100, 500" in str(e)
|
|
assert e.to_error_response_extra()["known_limits"] == [100, 500]
|
|
|
|
def test_notebook_limit_error_tolerates_invalid_base_url_env(self, monkeypatch):
|
|
"""NotebookLimitError should preserve quota context even if env config is invalid."""
|
|
monkeypatch.setenv("NOTEBOOKLM_BASE_URL", "https://evil.example.com")
|
|
|
|
e = NotebookLimitError(499, limit=500)
|
|
|
|
assert "499/500" in str(e)
|
|
base_url = (
|
|
str(e)
|
|
.split("Delete old notebooks at ", 1)[1]
|
|
.split(
|
|
" and try again.",
|
|
1,
|
|
)[0]
|
|
)
|
|
assert base_url == DEFAULT_BASE_URL
|
|
|
|
def test_source_not_found_has_source_id(self):
|
|
"""SourceNotFoundError stores source_id."""
|
|
e = SourceNotFoundError("src_456")
|
|
assert e.source_id == "src_456"
|
|
assert "src_456" in str(e)
|
|
|
|
def test_source_not_found_accepts_rpc_metadata(self):
|
|
"""SourceNotFoundError can carry ``method_id`` / ``raw_response`` for
|
|
callsites that wrap a degenerate RPC payload (v0.6.0)."""
|
|
e = SourceNotFoundError("src_xyz", method_id="getSourceXYZ", raw_response="[]")
|
|
assert e.source_id == "src_xyz"
|
|
assert e.method_id == "getSourceXYZ"
|
|
assert e.raw_response == "[]"
|
|
# Default empty list from RPCError parent.
|
|
assert e.found_ids == []
|
|
|
|
def test_source_processing_error_has_status(self):
|
|
"""SourceProcessingError stores source_id and status."""
|
|
e = SourceProcessingError("src_789", status=3)
|
|
assert e.source_id == "src_789"
|
|
assert e.status == 3
|
|
|
|
def test_source_timeout_error_has_timeout(self):
|
|
"""SourceTimeoutError stores source_id, timeout, and last_status."""
|
|
e = SourceTimeoutError("src_abc", timeout=60.0, last_status=1)
|
|
assert e.source_id == "src_abc"
|
|
assert e.timeout == 60.0
|
|
assert e.last_status == 1
|
|
|
|
def test_source_add_error_has_url(self):
|
|
"""SourceAddError stores url and cause."""
|
|
cause = ConnectionError("Failed")
|
|
e = SourceAddError("https://example.com", cause=cause)
|
|
assert e.url == "https://example.com"
|
|
assert e.cause is cause
|
|
|
|
def test_artifact_not_found_has_artifact_id(self):
|
|
"""ArtifactNotFoundError stores artifact_id and artifact_type, and the
|
|
message is well-formatted (no leading space; ``artifact_type``
|
|
capitalized; ``artifact_id`` appears in the string).
|
|
"""
|
|
e = ArtifactNotFoundError("art_123", artifact_type="audio")
|
|
assert e.artifact_id == "art_123"
|
|
assert e.artifact_type == "audio"
|
|
# Format pin (regression-guard for the pre-existing leading-space /
|
|
# capitalize-on-leading-space bug fixed in PR #1037):
|
|
assert str(e) == "Audio artifact not found: art_123"
|
|
# And specifically: ID must be in the string (RPCError has no
|
|
# __str__ override, so the message text is the entire string repr).
|
|
assert "art_123" in str(e)
|
|
assert not str(e).startswith(" "), "no leading space in message"
|
|
|
|
def test_artifact_not_found_without_type_has_clean_message(self):
|
|
"""When ``artifact_type`` is omitted, the message starts with
|
|
``Artifact`` (no leading space) and still includes the ID. Regression
|
|
guard for the pre-existing message-formatting bug fixed in PR #1037.
|
|
"""
|
|
e = ArtifactNotFoundError("art_xyz")
|
|
assert e.artifact_id == "art_xyz"
|
|
assert e.artifact_type is None
|
|
assert str(e) == "Artifact not found: art_xyz"
|
|
assert not str(e).startswith(" ")
|
|
|
|
def test_artifact_not_found_accepts_rpc_metadata(self):
|
|
"""ArtifactNotFoundError can carry ``method_id`` / ``raw_response`` for
|
|
callsites that wrap a degenerate RPC payload (v0.6.0)."""
|
|
e = ArtifactNotFoundError(
|
|
"art_xyz",
|
|
artifact_type="video",
|
|
method_id="listArtifacts",
|
|
raw_response="[[]]",
|
|
)
|
|
assert e.artifact_id == "art_xyz"
|
|
assert e.artifact_type == "video"
|
|
assert e.method_id == "listArtifacts"
|
|
assert e.raw_response == "[[]]"
|
|
assert e.found_ids == []
|
|
|
|
def test_artifact_not_ready_has_status(self):
|
|
"""ArtifactNotReadyError stores artifact_type, artifact_id, status."""
|
|
e = ArtifactNotReadyError("video", artifact_id="art_456", status="processing")
|
|
assert e.artifact_type == "video"
|
|
assert e.artifact_id == "art_456"
|
|
assert e.status == "processing"
|
|
|
|
def test_artifact_parse_error_has_details(self):
|
|
"""ArtifactParseError stores details and cause."""
|
|
cause = ValueError("Invalid JSON")
|
|
e = ArtifactParseError("quiz", details="Malformed response", cause=cause)
|
|
assert e.artifact_type == "quiz"
|
|
assert e.details == "Malformed response"
|
|
assert e.cause is cause
|
|
|
|
def test_artifact_download_error_has_details(self):
|
|
"""ArtifactDownloadError stores details and cause."""
|
|
e = ArtifactDownloadError("audio", details="404 Not Found", artifact_id="art_789")
|
|
assert e.artifact_type == "audio"
|
|
assert e.details == "404 Not Found"
|
|
assert e.artifact_id == "art_789"
|
|
|
|
def test_artifact_feature_unavailable_error_has_rpc_metadata(self):
|
|
"""ArtifactFeatureUnavailableError stores artifact type and RPC metadata."""
|
|
e = ArtifactFeatureUnavailableError("infographic", method_id="R7cb6c")
|
|
assert e.artifact_type == "infographic"
|
|
assert e.method_id == "R7cb6c"
|
|
assert isinstance(e, ArtifactError)
|
|
assert isinstance(e, RPCError)
|
|
assert str(e) == "Infographic generation is unavailable"
|
|
|
|
|
|
class TestAuthExtractionErrorScrubbing:
|
|
"""AuthExtractionError must redact credential-shaped substrings in its preview."""
|
|
|
|
def test_auth_extraction_error_scrubs_payload(self):
|
|
"""payload_preview must not leak ``f.sid=`` values from raw HTML.
|
|
|
|
Drift previews can capture multi-KB HTML snippets that contain live
|
|
session-id query params; ``scrub_secrets`` is applied during the
|
|
slice + whitespace-collapse pipeline so the redaction cannot be
|
|
defeated by a value that straddles the 5x preview boundary.
|
|
"""
|
|
# Token value lives in the prefix that will survive truncation.
|
|
payload = "<html><body>boot script f.sid=ABC123XYZ remaining markup</body></html>"
|
|
exc = AuthExtractionError("SNlM0e", payload)
|
|
|
|
assert "ABC123XYZ" not in exc.payload_preview
|
|
assert "ABC123XYZ" not in str(exc)
|
|
# Sanity: the redaction marker should be present so operators can see
|
|
# WHY the value is missing.
|
|
assert "f.sid=***" in exc.payload_preview
|
|
|
|
def test_auth_extraction_error_scrubs_secret_near_5x_boundary(self):
|
|
"""Secret straddling the 5x boundary is still scrubbed via the 10x slice.
|
|
|
|
The implementation pre-slices to 10x PREVIEW_LENGTH (2000 chars) before
|
|
scrubbing — large enough that a secret near the 5x cutoff (~1000 chars)
|
|
is fully contained in the pre-slice and gets redacted.
|
|
"""
|
|
prefix = "A" * (AuthExtractionError.PREVIEW_LENGTH * 5 - 10)
|
|
# Secret begins inside the 5x cut and continues past it — without the
|
|
# 10x pre-slice we'd see the unredacted "f.sid=SECRET" prefix.
|
|
payload = prefix + "f.sid=SECRET_NEAR_BOUNDARY_VALUE"
|
|
exc = AuthExtractionError("SNlM0e", payload)
|
|
|
|
assert "SECRET_NEAR_BOUNDARY" not in exc.payload_preview
|
|
assert "SECRET_NEAR_BOUNDARY" not in str(exc)
|
|
|
|
def test_auth_extraction_error_scrubs_google_api_key(self):
|
|
"""payload_preview must not leak a Google API key (``AIza…``) (codex #1517).
|
|
|
|
Red-first: the WIZ_global_data page embeds a Google API key
|
|
(``JrWMbf`` / ``B8SWKb`` / ``VqImj``); a drift preview captured during
|
|
token extraction renders it verbatim until the API-key shape is in the
|
|
shared ``scrub_secrets`` catch-alls. ``payload_preview`` already routes
|
|
through ``scrub_secrets``, so the registry addition covers it.
|
|
"""
|
|
api_key = "AIza" + "C" * 35
|
|
payload = f'<script>WIZ_global_data={{"JrWMbf":"{api_key}"}}</script>'
|
|
exc = AuthExtractionError("SNlM0e", payload)
|
|
|
|
assert api_key not in exc.payload_preview
|
|
assert api_key not in str(exc)
|
|
assert "AIza" not in exc.payload_preview
|
|
|
|
|
|
class TestCatchAllPattern:
|
|
"""Test that catching NotebookLMError catches all library exceptions."""
|
|
|
|
def test_catch_all_rpc_errors(self):
|
|
"""Catching NotebookLMError catches all RPC exceptions."""
|
|
for exc_class in [RPCError, AuthError, RateLimitError, ServerError, ClientError]:
|
|
with pytest.raises(NotebookLMError):
|
|
raise exc_class("test")
|
|
|
|
def test_catch_all_network_errors(self):
|
|
"""Catching NotebookLMError catches all network exceptions."""
|
|
for exc_class in [NetworkError, RPCTimeoutError]:
|
|
with pytest.raises(NotebookLMError):
|
|
raise exc_class("test")
|
|
|
|
def test_catch_all_domain_errors(self):
|
|
"""Catching NotebookLMError catches all domain exceptions."""
|
|
with pytest.raises(NotebookLMError):
|
|
raise NotebookNotFoundError("nb_123")
|
|
with pytest.raises(NotebookLMError):
|
|
raise SourceNotFoundError("src_456")
|
|
with pytest.raises(NotebookLMError):
|
|
raise ArtifactNotReadyError("audio")
|