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
100 lines
4.4 KiB
Python
100 lines
4.4 KiB
Python
"""Consistency gate: the REST error projection agrees with ``_app.errors.classify``.
|
|
|
|
Per ADR-0021, ``_app.errors.classify`` is the single neutral source of the
|
|
failure **category**; each adapter projects that category onto its own vocabulary
|
|
(the CLI's exit codes, MCP's manifest codes, the REST server's HTTP status). The
|
|
server's :data:`notebooklm.server._errors.CATEGORY_STATUS` maps every category
|
|
onto exactly one status.
|
|
|
|
This gate pins that projection: every :class:`ErrorCategory` member has a status
|
|
(no gaps, no extras), and a properly-constructed exemplar of each category
|
|
classifies into it. It mirrors ``test_mcp_classify_consistency.py``; the
|
|
``CATEGORY_STATUS`` import pulls in fastapi, so the whole module is skipped
|
|
cleanly when the ``server`` extra is absent (the canonical CI install).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
# The canonical contributor install omits the `server` extra (no fastapi). The
|
|
# server ``_errors`` module imports fastapi, so skip the whole module cleanly
|
|
# when the extra is absent rather than fail collection.
|
|
pytest.importorskip("fastapi")
|
|
|
|
from notebooklm import exceptions as exc # noqa: E402 - after importorskip guard
|
|
from notebooklm._app import SourceMutationError # noqa: E402 - after importorskip guard
|
|
from notebooklm._app.errors import ( # noqa: E402 - after importorskip guard
|
|
ErrorCategory,
|
|
classify,
|
|
)
|
|
from notebooklm.server._errors import CATEGORY_STATUS # noqa: E402 - after importorskip guard
|
|
|
|
# The HTTP status each neutral category projects onto. Hand-written as an
|
|
# INDEPENDENT ORACLE — do NOT "DRY" it into a shared import from
|
|
# ``CATEGORY_STATUS``; importing the production table would make the gate
|
|
# tautological. Hand-mirroring is what lets it catch a wrong edit.
|
|
_CATEGORY_TO_STATUS: dict[ErrorCategory, int] = {
|
|
ErrorCategory.NOT_FOUND: 404,
|
|
ErrorCategory.AUTH: 401,
|
|
ErrorCategory.RATE_LIMITED: 429,
|
|
ErrorCategory.VALIDATION: 400,
|
|
ErrorCategory.CONFIG: 500,
|
|
ErrorCategory.NETWORK: 502,
|
|
ErrorCategory.NOTEBOOK_LIMIT: 409,
|
|
ErrorCategory.ARTIFACT_TIMEOUT: 504,
|
|
ErrorCategory.TIMEOUT: 504,
|
|
ErrorCategory.SERVER: 502,
|
|
ErrorCategory.RPC: 502,
|
|
ErrorCategory.SOURCE_MUTATION: 422,
|
|
ErrorCategory.LIBRARY: 500,
|
|
ErrorCategory.UNEXPECTED: 500,
|
|
}
|
|
|
|
# One exemplar per category — the same exemplars the CLI/MCP consistency gates use.
|
|
_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_status_map_covers_every_category() -> None:
|
|
"""Every ``ErrorCategory`` has a status — a new category fails here."""
|
|
assert set(_CATEGORY_TO_STATUS) == set(ErrorCategory)
|
|
assert set(CATEGORY_STATUS) == set(ErrorCategory)
|
|
|
|
|
|
def test_table_matches_the_expected_projection() -> None:
|
|
"""``CATEGORY_STATUS`` equals this gate's independent expected projection."""
|
|
assert CATEGORY_STATUS == _CATEGORY_TO_STATUS
|
|
|
|
|
|
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)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("expected_category", "exception"),
|
|
_EXEMPLARS,
|
|
ids=[category.name for category, _ in _EXEMPLARS],
|
|
)
|
|
def test_status_matches_classify_category(
|
|
expected_category: ErrorCategory, exception: BaseException
|
|
) -> None:
|
|
classified = classify(exception)
|
|
assert classified.category is expected_category
|
|
assert CATEGORY_STATUS[classified.category] == _CATEGORY_TO_STATUS[expected_category]
|