Files
teng-lin--notebooklm-py/tests/_guardrails/test_classify_error_handler_consistency.py
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

105 lines
5.0 KiB
Python

"""Consistency gate: the CLI ``error_handler`` agrees with ``_app.errors.classify``.
Per ADR-0021, ``_app.errors.classify`` is the single neutral source of the failure
**category**; the CLI ``error_handler`` projects that category onto its own string-code
vocabulary (and the MCP server onto its manifest-pinned codes). Historically each layer
kept its own exception→code ladder, which could silently drift.
This gate pins the projection: for a properly-constructed exemplar of **every**
:class:`~notebooklm._app.errors.ErrorCategory`, the ``--json`` code the
``error_handler`` actually emits must equal the code this module maps that category to.
If someone adds an exception (or a category) and updates only one ladder, this fails.
It complements ``tests/unit/app/test_app_errors.py`` (every ``NotebookLMError`` subclass
classifies into *some* category) and ``tests/unit/cli/test_error_handler.py`` (each type
emits the expected envelope) by asserting the two are *the same decision*.
"""
from __future__ import annotations
import json
import pytest
from notebooklm import exceptions as exc
from notebooklm._app import SourceMutationError
from notebooklm._app.errors import ErrorCategory, classify
from notebooklm.cli.error_handler import handle_errors
# The CLI code each neutral category projects onto. The distinct codes
# (NOT_FOUND/AUTH_ERROR/…) recover 1:1; the transient/RPC-family categories
# (TIMEOUT/SERVER/RPC) and the library/source-mutation catch-alls currently
# fold into the generic ``NOTEBOOKLM_ERROR`` (a SourceMutationError that reaches
# the central handler — it is normally caught by the source command's own
# handler first — gets the library code). UNEXPECTED is the non-library bug code.
_CATEGORY_TO_CLI_CODE: dict[ErrorCategory, str] = {
ErrorCategory.NOT_FOUND: "NOT_FOUND",
ErrorCategory.AUTH: "AUTH_ERROR",
ErrorCategory.RATE_LIMITED: "RATE_LIMITED",
ErrorCategory.VALIDATION: "VALIDATION_ERROR",
ErrorCategory.CONFIG: "CONFIG_ERROR",
ErrorCategory.NETWORK: "NETWORK_ERROR",
ErrorCategory.NOTEBOOK_LIMIT: "NOTEBOOK_LIMIT",
ErrorCategory.ARTIFACT_TIMEOUT: "ARTIFACT_TIMEOUT",
ErrorCategory.TIMEOUT: "NOTEBOOKLM_ERROR",
ErrorCategory.SERVER: "NOTEBOOKLM_ERROR",
ErrorCategory.RPC: "NOTEBOOKLM_ERROR",
ErrorCategory.SOURCE_MUTATION: "NOTEBOOKLM_ERROR",
ErrorCategory.LIBRARY: "NOTEBOOKLM_ERROR",
ErrorCategory.UNEXPECTED: "UNEXPECTED_ERROR",
}
# One exemplar per category, constructed so the handler's attribute-dependent
# rendering (retry_after / NotebookLimit.to_error_response_extra / the artifact
# status block / NotFound id attrs) succeeds. ``ids`` name the category so a
# failure points at the drifting pair directly.
_EXEMPLARS: list[tuple[ErrorCategory, BaseException]] = [
(ErrorCategory.NOT_FOUND, exc.SourceNotFoundError("src_456")),
(ErrorCategory.AUTH, exc.AuthError("auth failed")),
(ErrorCategory.RATE_LIMITED, exc.RateLimitError("slow down", retry_after=5)),
(ErrorCategory.VALIDATION, exc.ValidationError("bad input")),
(ErrorCategory.CONFIG, exc.ConfigurationError("missing config")),
(ErrorCategory.NETWORK, exc.NetworkError("connection refused")),
(ErrorCategory.NOTEBOOK_LIMIT, exc.NotebookLimitError(499, limit=500)),
(ErrorCategory.ARTIFACT_TIMEOUT, exc.ArtifactTimeoutError("nb-1", "task-1", 30.0)),
(ErrorCategory.TIMEOUT, exc.WaitTimeoutError("generic wait timed out")),
(ErrorCategory.SERVER, exc.ServerError("upstream 503")),
(ErrorCategory.RPC, exc.RPCError("decode failed", method_id="abc123")),
(ErrorCategory.SOURCE_MUTATION, SourceMutationError("ambiguous", "AMBIGUOUS_ID")),
(ErrorCategory.LIBRARY, exc.NotebookLMError("some library error")),
(ErrorCategory.UNEXPECTED, RuntimeError("boom")),
]
def test_category_map_covers_every_category() -> None:
"""Every ``ErrorCategory`` has a CLI-code projection — a new category fails here."""
assert set(_CATEGORY_TO_CLI_CODE) == set(ErrorCategory)
def test_one_exemplar_per_category() -> None:
"""Exactly one exemplar per category, so the parametrization is exhaustive."""
assert {category for category, _ in _EXEMPLARS} == set(ErrorCategory)
def _emitted_code(exception: BaseException, capsys: pytest.CaptureFixture[str]) -> str:
"""The ``code`` field of the ``--json`` envelope the handler emits for ``exception``."""
with pytest.raises(SystemExit), handle_errors(json_output=True):
raise exception
return json.loads(capsys.readouterr().out)["code"]
@pytest.mark.parametrize(
("expected_category", "exception"),
_EXEMPLARS,
ids=[category.name for category, _ in _EXEMPLARS],
)
def test_error_handler_code_matches_classify_category(
expected_category: ErrorCategory,
exception: BaseException,
capsys: pytest.CaptureFixture[str],
) -> None:
# 1. classify lands the exemplar in the expected category.
assert classify(exception).category is expected_category
# 2. the CLI handler's emitted code is the projection of that category.
assert _emitted_code(exception, capsys) == _CATEGORY_TO_CLI_CODE[expected_category]