Merge branch 'main' into feature/m14-deployment

This commit is contained in:
2026-09-08 16:43:15 +02:00
9 changed files with 430 additions and 10 deletions

View File

@@ -14,6 +14,7 @@ from pydantic import SecretStr
from pronote_sync.config.settings import CalDAVSettings
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.pronote import PronoteData
@@ -85,19 +86,38 @@ def caldav_settings() -> CalDAVSettings:
)
@pytest.fixture
def sample_message() -> Message:
"""Message Pronote pour les tests.
:return: Message Pronote de test.
:rtype: Message
"""
return Message(
id="msg-001",
type=MessageType.INFORMATION,
title="Information de rentrée",
content="La rentrée est prévue le 1er septembre.",
author="Administration",
date=datetime(2026, 1, 15, 9, 0),
read=False,
)
@pytest.fixture
def pronote_data(
sample_lesson: Lesson,
sample_cancelled_lesson: Lesson,
sample_homework: Homework,
sample_school_event: SchoolEvent,
sample_message: Message,
) -> PronoteData:
"""Données Pronote de test avec des cours, devoirs et événements."""
"""Données Pronote de test avec des cours, devoirs, événements et messages."""
return PronoteData(
lessons=[sample_lesson, sample_cancelled_lesson],
homeworks=[sample_homework],
school_events=[sample_school_event],
messages=[],
messages=[sample_message],
target_date=date(2026, 1, 15),
generated_at=datetime(2026, 1, 15, 0, 0),
)

1
tests/e2e/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests end-to-end de l'interface en ligne de commande."""

189
tests/e2e/test_cli.py Normal file
View File

@@ -0,0 +1,189 @@
"""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

56
tests/fixtures/pronote-6e.ics vendored Normal file
View File

@@ -0,0 +1,56 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Index Education//Pronote//FR
X-WR-CALNAME:Classe de 6e
BEGIN:VEVENT
UID:Edt_22222@index-education.net-20260908T140000Z-Index-Education
DTSTAMP:20260908T140000Z
DTSTART:20260908T140000Z
DTEND:20260908T150000Z
SUMMARY:SVT
CATEGORIES:Cours
DESCRIPTION:<div>
Matière : SVT
Professeur : M. Dubois
Salle : 104
Groupe : Classe entière
<strong>Contenu pédagogique :
</strong>
Découverte de la cellule et de ses constituants.
<strong>Pour le 15/09/2026 :
</strong>
Lire le chapitre 2 et schématiser une cellule végétale.
<strong>Donné le 08/09/2026 :
</strong>
Lire le chapitre 2 et schématiser une cellule végétale.
</div>
END:VEVENT
BEGIN:VEVENT
UID:Edt_33333@index-education.net-20260908T140000Z-Index-Education
DTSTAMP:20260908T140000Z
DTSTART:20260909T100000Z
DTEND:20260909T110000Z
SUMMARY:Histoire-Géographie
CATEGORIES:Cours - Cours modifié
DESCRIPTION:<div>
Matière : Histoire-Géographie
Professeur : Mme Lefevre
Salle : 203
Groupe : Classe entière
<strong>Contenu pédagogique :
</strong>
Les grands repères du temps long : la Préhistoire.
</div>
END:VEVENT
BEGIN:VEVENT
UID:Edt_44444@index-education.net-20260908T140000Z-Index-Education
DTSTAMP:20260908T140000Z
DTSTART;VALUE=DATE:20260928
DTEND;VALUE=DATE:20260929
SUMMARY:Sortie pédagogique
CATEGORIES:Sortie scolaire
DESCRIPTION:Journée de sortie pédagogique au musée d'histoire naturelle.
END:VEVENT
END:VCALENDAR