Files
teng-lin--notebooklm-py/tests/unit/cli/test_json_validation_contract.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

200 lines
6.5 KiB
Python

"""Root-boundary guard for ``--json`` validation errors.
Click validates option values, missing arguments, and unknown options before
command callbacks run. Those failures must still honor ``--json`` so automation
never receives usage text when it asked for a machine-readable error.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from unittest.mock import AsyncMock, patch
import click
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
import notebooklm.cli.helpers as helpers_module
from notebooklm.cli.grouped import SectionedGroup
from notebooklm.notebooklm_cli import cli
@pytest.fixture
def runner() -> CliRunner:
# Click 8.3 captures stdout/stderr separately by default; mix_stderr was removed.
return CliRunner()
@pytest.fixture
def mock_auth_env() -> Iterator[None]:
with (
patch.object(helpers_module, "load_auth_from_storage") as mock_load,
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_load.return_value = {
"SID": "test",
"HSID": "test",
"SSID": "test",
"APISID": "test",
"SAPISID": "test",
}
mock_fetch.return_value = ("csrf", "session")
yield
@pytest.mark.parametrize(
"case_id,argv",
[
("invalid_limit", ["list", "--limit", "-1", "--json"]),
("invalid_interval", ["research", "wait", "--interval", "0", "--json"]),
("invalid_retry", ["generate", "audio", "--retry", "-1", "--json"]),
("missing_argument", ["note", "get", "--json"]),
("unknown_option", ["list", "--json", "--definitely-not-an-option"]),
("root_callback_validation", ["--quiet", "-v", "status", "--json"]),
],
ids=lambda value: value if isinstance(value, str) else None,
)
def test_json_validation_errors_emit_json(
case_id: str,
argv: list[str],
runner: CliRunner,
) -> None:
result = runner.invoke(cli, argv, catch_exceptions=False)
assert result.exit_code != 0, result.output
assert result.stdout.strip(), f"{case_id}: expected JSON error on stdout"
assert result.stderr == "", f"{case_id}: stderr should stay empty under --json"
assert result.output == result.stdout, f"{case_id}: combined output should be JSON only"
payload = json.loads(result.stdout)
assert payload["error"] is True
assert payload["code"] == "VALIDATION_ERROR"
assert payload["message"]
def test_command_body_click_validation_emit_json(
runner: CliRunner,
mock_auth_env,
) -> None:
result = runner.invoke(
cli,
["note", "create", "positional", "--content", "flag", "-n", "nb_123", "--json"],
catch_exceptions=False,
)
assert result.exit_code != 0, result.output
assert result.stderr == ""
assert result.output == result.stdout
payload = json.loads(result.stdout)
assert payload["error"] is True
assert payload["code"] == "VALIDATION_ERROR"
assert "Cannot use both" in payload["message"]
def test_json_abort_emit_json(runner: CliRunner) -> None:
@click.group(cls=SectionedGroup)
def root() -> None:
pass
@root.command()
@click.option("--json", "json_output", is_flag=True)
def aborting(json_output: bool) -> None: # noqa: ARG001
raise click.Abort()
result = runner.invoke(root, ["aborting", "--json"], catch_exceptions=False)
assert result.exit_code == 1
assert result.stderr == ""
assert result.output == result.stdout
payload = json.loads(result.stdout)
assert payload == {
"error": True,
"code": "CANCELLED",
"message": "Cancelled by user",
}
def _walk_leaf_commands(
command: click.BaseCommand,
path: tuple[str, ...] = (),
) -> Iterator[tuple[tuple[str, ...], click.Command]]:
if isinstance(command, click.Group):
for name, child in command.commands.items():
yield from _walk_leaf_commands(child, path + (name,))
return
if isinstance(command, click.Command):
yield path, command
def _has_json_option(command: click.Command) -> bool:
return any(
isinstance(param, click.Option) and "--json" in param.opts for param in command.params
)
def _invalid_value_for_type(param_type: click.ParamType) -> str | None:
if isinstance(param_type, click.IntRange):
if param_type.min is not None:
return str(param_type.min - 1)
if param_type.max is not None:
return str(param_type.max + 1)
return "not-an-int"
if isinstance(param_type, click.types.IntParamType):
return "not-an-int"
if isinstance(param_type, click.Choice):
return "__not_a_valid_choice__"
return None
def _validated_json_option_cases() -> list[pytest.ParameterSet]:
cases: list[pytest.ParameterSet] = []
for path, command in _walk_leaf_commands(cli):
if not _has_json_option(command):
continue
for param in command.params:
if (
not isinstance(param, click.Option)
or "--json" in param.opts
or param.is_flag
or param.count
):
continue
value = _invalid_value_for_type(param.type)
if value is None:
continue
opt = next((name for name in param.opts if name.startswith("--")), param.opts[0])
case_id = "_".join((*path, opt.lstrip("-").replace("-", "_")))
cases.append(pytest.param(case_id, [*path, opt, value, "--json"], id=case_id))
return cases
@pytest.mark.parametrize(
"case_id,argv",
_validated_json_option_cases(),
)
def test_validated_json_options_emit_json_on_bad_values(
case_id: str,
argv: list[str],
runner: CliRunner,
) -> None:
result = runner.invoke(cli, argv, catch_exceptions=False)
assert result.exit_code != 0, result.output
assert result.stderr == "", f"{case_id}: stderr should stay empty under --json"
assert result.output == result.stdout, f"{case_id}: combined output should be JSON only"
payload = json.loads(result.stdout)
assert payload["error"] is True
assert payload["code"] == "VALIDATION_ERROR"
def test_text_validation_errors_keep_click_usage_output(runner: CliRunner) -> None:
result = runner.invoke(cli, ["list", "--limit", "-1"])
assert result.exit_code == 2
assert result.stdout == ""
assert "Usage:" in result.stderr
assert not result.stdout.strip().startswith("{")