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

320 lines
12 KiB
Python

"""Tests for ``notebooklm._app.errors.classify``."""
from __future__ import annotations
import dataclasses
import pytest
from notebooklm import exceptions as exc
from notebooklm._app.download import DownloadPlanValidationError
from notebooklm._app.errors import (
CATEGORY_HINTS,
ClassifiedError,
ErrorCategory,
classify,
did_you_mean_hint,
is_retriable,
)
from notebooklm._app.source_add import SourceAddValidationError
from notebooklm._app.source_mutations import SourceMutationError
def _all_concrete_subclasses(root: type) -> list[type]:
"""Return every concrete (instantiable) subclass of ``root``, incl. ``root``."""
seen: set[type] = set()
out: list[type] = []
stack = [root]
while stack:
cls = stack.pop()
if cls in seen:
continue
seen.add(cls)
out.append(cls)
stack.extend(cls.__subclasses__())
return out
def _instance_without_init(cls: type) -> BaseException:
"""Build an instance bypassing ``__init__`` (constructors vary widely).
``classify`` is purely structural (``isinstance``), so an
``__init__``-less instance classifies identically to a fully-built one;
this lets the coverage test enumerate every subclass without knowing each
constructor's signature.
"""
return cls.__new__(cls) # type: ignore[no-any-return]
def test_classify_returns_frozen_classified_error() -> None:
result = classify(exc.NotFoundError())
assert isinstance(result, ClassifiedError)
with pytest.raises(dataclasses.FrozenInstanceError):
result.category = ErrorCategory.AUTH # type: ignore[misc]
@pytest.mark.parametrize("cls", _all_concrete_subclasses(exc.NotebookLMError))
def test_every_library_exception_classifies_as_a_library_category(cls: type) -> None:
"""No ``NotebookLMError`` subclass falls through to ``UNEXPECTED``."""
result = classify(_instance_without_init(cls))
assert isinstance(result.category, ErrorCategory)
assert result.category is not ErrorCategory.UNEXPECTED, (
f"{cls.__name__} classified as UNEXPECTED — every library exception "
"must map to a specific category."
)
@pytest.mark.parametrize(
("exception", "expected_category", "expected_retriable"),
[
(
exc.ArtifactTimeoutError("nb", "task", 1.0),
ErrorCategory.ARTIFACT_TIMEOUT,
True,
),
(exc.SourceTimeoutError("src", 1.0), ErrorCategory.TIMEOUT, True),
(exc.ResearchTimeoutError("nb", "task", 1.0), ErrorCategory.TIMEOUT, True),
(exc.NotebookLimitError(5), ErrorCategory.NOTEBOOK_LIMIT, False),
(exc.RateLimitError("slow down"), ErrorCategory.RATE_LIMITED, True),
(exc.ServerError("boom"), ErrorCategory.SERVER, True),
(exc.NetworkError("offline"), ErrorCategory.NETWORK, True),
(exc.RPCTimeoutError("timed out"), ErrorCategory.NETWORK, True),
(exc.AuthError("expired"), ErrorCategory.AUTH, False),
(exc.ValidationError("bad"), ErrorCategory.VALIDATION, False),
(exc.ConfigurationError("no auth"), ErrorCategory.CONFIG, False),
(exc.SourceNotFoundError("src"), ErrorCategory.NOT_FOUND, False),
(exc.NotebookNotFoundError("nb"), ErrorCategory.NOT_FOUND, False),
(exc.DecodingError("schema drift"), ErrorCategory.RPC, False),
(exc.NotebookLMError("generic"), ErrorCategory.LIBRARY, False),
# ``_app``-raised errors re-based onto the public hierarchy (§11). The
# two validation errors fold into VALIDATION via their ValidationError
# base; SourceMutationError keeps its own category so adapters recover
# its carried ``.code`` taxonomy.
(
DownloadPlanValidationError("Cannot specify both --force and --no-clobber"),
ErrorCategory.VALIDATION,
False,
),
(SourceAddValidationError("bad url"), ErrorCategory.VALIDATION, False),
(
SourceMutationError("ambiguous", "AMBIGUOUS_ID"),
ErrorCategory.SOURCE_MUTATION,
False,
),
(ValueError("not ours"), ErrorCategory.UNEXPECTED, False),
(RuntimeError("not ours"), ErrorCategory.UNEXPECTED, False),
],
)
def test_class_sensitive_classification(
exception: BaseException,
expected_category: ErrorCategory,
expected_retriable: bool,
) -> None:
result = classify(exception)
assert result.category is expected_category
assert result.retriable is expected_retriable
def test_artifact_timeout_distinct_from_generic_wait_timeout() -> None:
artifact = classify(exc.ArtifactTimeoutError("nb", "task", 1.0)).category
generic = classify(exc.SourceTimeoutError("src", 1.0)).category
assert artifact is ErrorCategory.ARTIFACT_TIMEOUT
assert generic is ErrorCategory.TIMEOUT
assert artifact is not generic
def test_artifact_timeout_subclasses_also_classify_as_artifact_timeout() -> None:
pending = exc.ArtifactPendingTimeoutError("nb", "task", 1.0)
in_progress = exc.ArtifactInProgressTimeoutError("nb", "task", 1.0)
assert classify(pending).category is ErrorCategory.ARTIFACT_TIMEOUT
assert classify(in_progress).category is ErrorCategory.ARTIFACT_TIMEOUT
@pytest.mark.parametrize("code", [5, "5"])
def test_client_error_status_5_classifies_as_not_found(code: int | str) -> None:
"""gRPC status-5 (raised as ``ClientError(rpc_code=5)``) is NOT_FOUND, not RPC.
The decoder raises a bare ``ClientError`` (not a ``NotFoundError``) for a
status-5 result; ``classify`` must recover NOT_FOUND. Both the int and the
string form of ``rpc_code`` are normalized.
"""
result = classify(exc.ClientError("missing", rpc_code=code))
assert result.category is ErrorCategory.NOT_FOUND
assert result.retriable is False
def test_client_error_status_7_is_not_swept_into_not_found() -> None:
"""Code 7 (permission-denied) from the same decoder site stays generic RPC."""
result = classify(exc.ClientError("denied", rpc_code=7))
assert result.category is ErrorCategory.RPC
def test_client_error_without_rpc_code_stays_rpc() -> None:
"""A ClientError carrying no rpc_code falls through to the RPC catch-all."""
assert classify(exc.ClientError("client 4xx")).category is ErrorCategory.RPC
def test_bare_rpc_error_unaffected_by_status_5_branch() -> None:
"""The RPC exemplar (a bare RPCError, no rpc_code) keeps classifying as RPC.
This is the exemplar the cross-adapter consistency gate uses; the additive
status-5 branch must not perturb it.
"""
assert classify(exc.RPCError("decode failed")).category is ErrorCategory.RPC
def test_not_found_wins_over_rpc_base() -> None:
# *NotFoundError mixes in RPCError; classification must prefer NOT_FOUND.
assert isinstance(exc.SourceNotFoundError("x"), exc.RPCError)
assert classify(exc.SourceNotFoundError("x")).category is ErrorCategory.NOT_FOUND
def test_research_task_mismatch_is_validation() -> None:
# ResearchTaskMismatchError subclasses ValidationError.
err = exc.ResearchTaskMismatchError(task_id="A", source_research_task_id="B")
assert classify(err).category is ErrorCategory.VALIDATION
@pytest.mark.parametrize(
("app_error", "expected_base", "expected_category"),
[
(
DownloadPlanValidationError("boom"),
exc.ValidationError,
ErrorCategory.VALIDATION,
),
(SourceAddValidationError("boom"), exc.ValidationError, ErrorCategory.VALIDATION),
(
SourceMutationError("boom", "NOT_FOUND"),
exc.NotebookLMError,
ErrorCategory.SOURCE_MUTATION,
),
],
)
def test_app_raised_errors_are_in_public_hierarchy_and_classify(
app_error: BaseException,
expected_base: type,
expected_category: ErrorCategory,
) -> None:
"""Every ``_app``-raised exception is a ``NotebookLMError`` and classifies (§11)."""
assert isinstance(app_error, exc.NotebookLMError)
assert isinstance(app_error, expected_base)
result = classify(app_error)
assert result.category is expected_category
assert result.category is not ErrorCategory.UNEXPECTED
def test_source_mutation_error_keeps_cli_attributes() -> None:
"""Re-basing onto NotebookLMError must not drop the CLI-read attributes."""
err = SourceMutationError(
"ambiguous id",
"AMBIGUOUS_ID",
{"source_id": "abc"},
status_message="[dim]Matched: abc[/dim]",
)
assert err.message == "ambiguous id"
assert err.code == "AMBIGUOUS_ID"
assert err.extra == {"source_id": "abc"}
assert err.status_message == "[dim]Matched: abc[/dim]"
def test_download_plan_validation_error_keeps_code_and_message() -> None:
"""``download_cmd`` reads ``.message`` / ``.code`` for its --json envelope."""
err = DownloadPlanValidationError("Cannot specify both --force and --no-clobber")
assert err.message == "Cannot specify both --force and --no-clobber"
assert err.code == "VALIDATION_ERROR"
assert str(err) == "Cannot specify both --force and --no-clobber"
def test_retriable_only_for_transient_categories() -> None:
transient = {
ErrorCategory.RATE_LIMITED,
ErrorCategory.SERVER,
ErrorCategory.TIMEOUT,
ErrorCategory.ARTIFACT_TIMEOUT,
ErrorCategory.NETWORK,
}
samples = {
ErrorCategory.NOT_FOUND: exc.SourceNotFoundError("x"),
ErrorCategory.AUTH: exc.AuthError("x"),
ErrorCategory.VALIDATION: exc.ValidationError("x"),
ErrorCategory.CONFIG: exc.ConfigurationError("x"),
ErrorCategory.NOTEBOOK_LIMIT: exc.NotebookLimitError(1),
ErrorCategory.RPC: exc.DecodingError("x"),
ErrorCategory.LIBRARY: exc.NotebookLMError("x"),
ErrorCategory.UNEXPECTED: ValueError("x"),
ErrorCategory.RATE_LIMITED: exc.RateLimitError("x"),
ErrorCategory.SERVER: exc.ServerError("x"),
ErrorCategory.NETWORK: exc.NetworkError("x"),
ErrorCategory.TIMEOUT: exc.SourceTimeoutError("x", 1.0),
ErrorCategory.ARTIFACT_TIMEOUT: exc.ArtifactTimeoutError("nb", "task", 1.0),
}
for category, sample in samples.items():
result = classify(sample)
assert result.category is category
assert result.retriable is (category in transient)
def test_classify_retriable_delegates_to_is_retriable() -> None:
"""``classify`` reads retriability from ``is_retriable`` (single source of truth).
Rather than re-inlining ``category in _RETRIABLE_CATEGORIES``, ``classify``
must agree with :func:`is_retriable` for both transient and deterministic
exceptions — so the two never drift.
"""
samples = [
exc.RateLimitError("x"),
exc.ServerError("x"),
exc.NetworkError("x"),
exc.AuthError("x"),
exc.ValidationError("x"),
ValueError("x"),
]
for sample in samples:
result = classify(sample)
assert result.retriable is is_retriable(result.category)
def test_category_hints_are_surface_neutral() -> None:
"""The shared REST+MCP hints must not name a specific tool or CLI command.
``CATEGORY_HINTS`` is consumed by BOTH the MCP projector and the REST error
body, so a hint that names ``studio_status`` or ``notebooklm login`` would be
wrong on the other surface. Guards the F2 neutralization.
"""
banned = ("studio_status", "notebooklm ", "`")
for category, hint in CATEGORY_HINTS.items():
if hint is None:
continue
for token in banned:
assert token not in hint, (
f"{category} hint leaks surface-specific token {token!r}: {hint}"
)
assert CATEGORY_HINTS[ErrorCategory.AUTH] == "Re-authenticate and retry."
assert "task status" in CATEGORY_HINTS[ErrorCategory.ARTIFACT_TIMEOUT]
# --- did_you_mean_hint (issue #1787) ---------------------------------------
def test_did_you_mean_hint_includes_id_and_title() -> None:
"""The hint carries id AND title so a flat-string MCP client can retry by id."""
hint = did_you_mean_hint([{"id": "abc123", "title": "Scientific PDF Parsing"}])
assert hint.startswith("Did you mean:")
assert "abc123" in hint
assert "Scientific PDF Parsing" in hint
assert hint.endswith("Pass the full title or id.")
def test_did_you_mean_hint_lists_multiple_candidates() -> None:
hint = did_you_mean_hint([{"id": "id1", "title": "Alpha"}, {"id": "id2", "title": "Beta"}])
assert "id1" in hint and "id2" in hint
assert "Alpha" in hint and "Beta" in hint