"""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