Implement the CLI entry point for pronote-sync: cli/main.py: - main() entry point with --dry-run (tri-state: None defers to settings, True overrides) and --log-level (choices: DEBUG/INFO/WARNING/ERROR/CRITICAL) - setup_logging called before settings load (to capture config errors), then reconfigured with settings.app.log_level - PipelineRunner.from_settings() as composition root, runner.run() - Return codes: 0 success, 1 failure, 2 argparse rejection - _safe_traceback: strips exception messages, replaces with "erreur expurgée", walks __cause__/__context__ with cycle protection - _settings_secrets: collects redaction_secrets() + usernames + JID/recipient - All error messages redacted via redact_secrets() with configured secrets - DEBUG-level traceback only shown when DEBUG is enabled cli/__init__.py: - Module docstring added (French, Sphinx/reST) tests/e2e/test_cli.py (8 tests): - Dry-run and log-level propagation to composition root - Configured dry-run preserved (tri-state None) - Success with warnings returns 0 - Pipeline error redaction at DEBUG (sentinel secret) - Configuration failure redacted traceback at DEBUG - Pronote username non-disclosure - Unexpected pipeline exception: redacted traceback at DEBUG, no traceback at INFO - Argparse rejection of unknown log level (exit code 2) Coverage: cli/ 94.74%, 627 total tests pass. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
190 lines
6.6 KiB
Python
190 lines
6.6 KiB
Python
"""Tests de l'interface en ligne de commande ``pronote-sync``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
from pytest_mock import MockerFixture
|
|
|
|
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
|
|
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
|
|
from pronote_sync.models.pronote import PronoteData
|
|
|
|
|
|
def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""La CLI propage les options au logger et au runner injecté."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
settings = Settings(app=AppSettings(log_level="WARNING"))
|
|
load_settings = mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
|
|
setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging")
|
|
runner = mocker.Mock()
|
|
runner.run.return_value = (mocker.Mock(spec=PronoteData), [])
|
|
composition_root = mocker.patch(
|
|
"pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner
|
|
)
|
|
|
|
exit_code = main(["--dry-run", "--log-level", "DEBUG"])
|
|
|
|
assert exit_code == 0
|
|
load_settings.assert_called_once_with()
|
|
assert setup_logging.call_args_list == [mocker.call("DEBUG"), mocker.call("DEBUG")]
|
|
composition_root.assert_called_once_with(settings, dry_run=True)
|
|
runner.run.assert_called_once_with()
|
|
|
|
|
|
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
|
|
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
|
|
setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging")
|
|
runner = mocker.Mock()
|
|
runner.run.return_value = (
|
|
mocker.Mock(spec=PronoteData),
|
|
[PipelineWarning("Avertissement non bloquant")],
|
|
)
|
|
composition_root = mocker.patch(
|
|
"pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner
|
|
)
|
|
|
|
exit_code = main([])
|
|
|
|
assert exit_code == 0
|
|
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
|
|
composition_root.assert_called_once_with(settings, dry_run=None)
|
|
runner.run.assert_called_once_with()
|
|
|
|
|
|
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
|
|
mocker: MockerFixture,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""Les diagnostics de pipeline restent expurgés, même au niveau DEBUG."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
secret = "M12_PIPELINE_SECRET" # pragma: allowlist secret
|
|
settings = Settings(ai=AISettings(api_key=SecretStr(secret)))
|
|
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
|
|
runner = mocker.Mock()
|
|
runner.run.return_value = (
|
|
None,
|
|
[PipelineCriticalError(f"Échec distant avec le secret {secret}")],
|
|
)
|
|
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
|
|
|
|
exit_code = main(["--log-level", "DEBUG"])
|
|
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 1
|
|
assert secret not in output
|
|
assert "REDACTED" in output
|
|
assert "Traceback" not in output
|
|
|
|
|
|
def test_main_displays_a_redacted_configuration_traceback_at_debug_level(
|
|
mocker: MockerFixture,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""Une erreur de configuration DEBUG conserve son traceback sans son secret."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
secret = "M12_CONFIGURATION_SECRET" # pragma: allowlist secret
|
|
mocker.patch(
|
|
"pronote_sync.cli.main.load_settings",
|
|
side_effect=ValueError(f"configuration invalide: {secret}"),
|
|
)
|
|
|
|
exit_code = main(["--log-level", "DEBUG"])
|
|
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 1
|
|
assert secret not in output
|
|
assert "Configuration invalide ou indisponible." in output
|
|
assert "Traceback" in output
|
|
|
|
|
|
def test_main_does_not_disclose_a_configured_pronote_username(
|
|
mocker: MockerFixture,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""Les erreurs critiques ne divulguent pas un identifiant Pronote configuré."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
username = "m12-parent-identifier"
|
|
settings = Settings(pronote=PronoteSettings(username=username))
|
|
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
|
|
runner = mocker.Mock()
|
|
runner.run.return_value = (
|
|
None,
|
|
[PipelineCriticalError(f"Échec distant pour l'identifiant {username}")],
|
|
)
|
|
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
|
|
|
|
exit_code = main([])
|
|
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 1
|
|
assert username not in output
|
|
assert "REDACTED" in output
|
|
|
|
|
|
def test_main_rejects_an_unknown_log_level() -> None:
|
|
"""La CLI rejette les niveaux de journalisation hors contrat."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
with pytest.raises(SystemExit) as error:
|
|
main(["--log-level", "VERBOSE"])
|
|
|
|
assert error.value.code == 2
|
|
|
|
|
|
def test_main_logs_redacted_traceback_when_pipeline_raises_unexpectedly(
|
|
mocker: MockerFixture,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""Une exception inattendue du pipeline produit un traceback expurgé en DEBUG."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
secret = "M12_UNEXPECTED_SECRET" # pragma: allowlist secret
|
|
settings = Settings(ai=AISettings(api_key=SecretStr(secret)))
|
|
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
|
|
mocker.patch(
|
|
"pronote_sync.cli.main.PipelineRunner.from_settings",
|
|
side_effect=RuntimeError(f"Erreur interne avec {secret}"),
|
|
)
|
|
|
|
exit_code = main(["--log-level", "DEBUG"])
|
|
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 1
|
|
assert secret not in output
|
|
assert "Traceback" in output
|
|
assert "erreur expurgée" in output
|
|
|
|
|
|
def test_main_does_not_show_traceback_at_info_level(
|
|
mocker: MockerFixture,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""En niveau INFO, aucune pile n'est affichée pour une erreur inattendue."""
|
|
from pronote_sync.cli.main import main
|
|
|
|
mocker.patch("pronote_sync.cli.main.load_settings", return_value=Settings())
|
|
mocker.patch(
|
|
"pronote_sync.cli.main.PipelineRunner.from_settings",
|
|
side_effect=RuntimeError("Erreur interne"),
|
|
)
|
|
|
|
exit_code = main([])
|
|
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 1
|
|
assert "Traceback" not in output
|
|
assert "Échec inattendu du pipeline." in output
|