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

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]