"""Tests unitaires pour la logique de repli iCal / pronotepy. Ce module valide le comportement du module :mod:`pronote_sync.sources.pronote.fallback` et de son implémentation :class:`PronoteFetcher`. Les tests couvrent : - La sélection de la source d'agenda (``ical``, ``pronotepy``, ``auto``) et de devoirs, - Le repli automatique iCal → pronotepy en mode ``auto``, - La levée de :class:`PipelineCriticalError` lorsque toutes les sources échouent, - Le masquage des secrets dans les messages d'erreur, - Les méthodes toujours basées sur pronotepy (messages, informations). Tous les appels réseau et les interactions avec pronotepy sont mockés. """ from __future__ import annotations from datetime import date, datetime from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest from pydantic import SecretStr from pronote_sync.config.settings import PronoteSettings, Settings from pronote_sync.errors import PipelineCriticalError from pronote_sync.models.agenda import ( HomeworkBlock, Lesson, LessonStatus, SchoolEvent, SchoolEventKind, ) from pronote_sync.models.homework import Homework from pronote_sync.models.message import Message, MessageType from pronote_sync.sources.pronote.fallback import PronoteFetcher if TYPE_CHECKING: from typing import Protocol class _MockPronoteClientProtocol(Protocol): def get_messages(self) -> list[Message]: ... def get_informations(self) -> list[Message]: ... def get_agenda_fallback( self, start: date, end: date ) -> tuple[list[Lesson], list[Homework]]: ... @pytest.fixture(name="mock_settings") def fixture_mock_settings() -> Settings: """Fixture fournissant une configuration Settings adaptée aux tests. :return: Instance de :class:`Settings` avec des valeurs sûres pour les tests. :rtype: Settings """ return Settings( pronote=PronoteSettings( ical_url=SecretStr("file:///fake/ical.ics"), agenda_source="auto", homework_source="auto", username="testuser", password=SecretStr("testpass"), ent="ent", ), app=Settings().app, ) @pytest.fixture(name="mock_fetcher") def fixture_mock_fetcher(mock_settings: Settings) -> PronoteFetcher: """Fixture fournissant une instance de :class:`PronoteFetcher` prête à l'emploi. :param mock_settings: Configuration de test. :return: Instance de :class:`PronoteFetcher` pour les tests. :rtype: PronoteFetcher """ client: _MockPronoteClientProtocol = MagicMock() return PronoteFetcher(settings=mock_settings, pronote_client=client) def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None: """Test la récupération de l'agenda en mode source iCal. On mock ``fetch_ical`` et ``parse_ical`` pour retourner des cours et événements. On vérifie que le fetcher retourne bien ces données. :param mock_fetcher: Fetcher de test. :return: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) lessons = [ Lesson( id="l1", start=start_dt, end=end_dt, subject="Maths", teachers=("Dupont",), rooms=("S1",), group="2ndeA", status=LessonStatus.NORMAL, content=None, ) ] events = [ SchoolEvent( kind=SchoolEventKind.HOLIDAY, label="Vacances", from_date=date(2025, 9, 1), to_date=date(2025, 9, 15), ) ] with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, ): m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..." m_parse_ical.return_value = (lessons, [], events) result_lessons, result_events = mock_fetcher.fetch_agenda() assert result_lessons == lessons assert result_events == events m_fetch_ical.assert_called_once() m_parse_ical.assert_called_once() def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None: """Test la récupération de l'agenda en mode source pronotepy. On mock ``get_agenda_fallback`` du client pour retourner des cours. On vérifie que le fetcher retourne ces cours (événements scolaires vides). :param mock_fetcher: Fetcher de test. :return: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) lessons = [ Lesson( id="l1", start=start_dt, end=end_dt, subject="Physique", teachers=("Martin",), rooms=("Labo1",), group="1ereB", status=LessonStatus.NORMAL, content=None, ) ] client = MagicMock() client.get_agenda_fallback.return_value = (lessons, []) mock_fetcher._pronote_client = client result_lessons, result_events = mock_fetcher.fetch_agenda() assert result_lessons == lessons assert result_events == [] client.get_agenda_fallback.assert_called_once() def test_fetch_agenda_auto_ical_success(mock_fetcher: PronoteFetcher) -> None: """Test le mode auto : succès de l'iCal, pronotepy non appelé. On mock iCal pour réussir, et on vérifie que pronotepy n'est pas sollicité. :param mock_fetcher: Fetcher de test. :return: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) lessons = [ Lesson( id="l1", start=start_dt, end=end_dt, subject="SVT", teachers=("Durand",), rooms=("S2",), group="3emeC", status=LessonStatus.NORMAL, content=None, ) ] with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, ): m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..." m_parse_ical.return_value = (lessons, [], []) result_lessons, _ = mock_fetcher.fetch_agenda() assert result_lessons == lessons def test_fetch_agenda_auto_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -> None: """Test le mode auto : échec iCal, repli sur pronotepy. On mock iCal pour échouer, pronotepy pour réussir. On vérifie que pronotepy est appelé. :param mock_fetcher: Fetcher de test. :return: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) lessons = [ Lesson( id="l1", start=start_dt, end=end_dt, subject="Histoire", teachers=("Lefevre",), rooms=("S3",), group="2ndeD", status=LessonStatus.NORMAL, content=None, ) ] with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, ): m_fetch_ical.side_effect = OSError("iCal unreachable") m_parse_ical.side_effect = OSError("iCal parse error") client = MagicMock() client.get_agenda_fallback.return_value = (lessons, []) mock_fetcher._pronote_client = client result_lessons, _ = mock_fetcher.fetch_agenda() assert result_lessons == lessons client.get_agenda_fallback.assert_called_once() def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: """Test le mode auto : échec des deux sources → PipelineCriticalError. On mock iCal et pronotepy pour échouer. On vérifie la levée de l'erreur critique. :param mock_fetcher: Fetcher de test. :return: None """ with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, ): m_fetch_ical.side_effect = OSError("iCal unreachable") m_parse_ical.side_effect = OSError("iCal parse error") client = MagicMock() mock_fetcher._pronote_client = client with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_agenda() assert "iCal et pronotepy" in str(exc_info.value) def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None: """Test le mode ical : échec → PipelineCriticalError masquée. On mock iCal pour échouer. On vérifie que l'erreur brute est masquée dans la levée. :param mock_fetcher: Fetcher de test. :return: None """ # Override settings to use ical mode explicitly mock_fetcher._settings.pronote.agenda_source = "ical" with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, ): m_fetch_ical.side_effect = OSError( "Impossible de lire le fichier iCal file:///fake/ical.ics : iCal unreachable" ) m_parse_ical.side_effect = OSError("iCal parse error") with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_agenda() assert "Impossible de récupérer l'agenda : la source iCal a échoué" in str(exc_info.value) # Vérifie que le message ne contient pas de secret assert "file:///fake/ical.ics" not in str(exc_info.value) def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> None: """Test le mode pronotepy : échec → PipelineCriticalError masquée. On mock pronotepy pour échouer. On vérifie que l'erreur est masquée dans la levée. :param mock_fetcher: Fetcher de test. :return: None """ # Override settings to use pronotepy mode explicitly mock_fetcher._settings.pronote.agenda_source = "pronotepy" client = MagicMock() mock_fetcher._pronote_client = client with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_agenda() assert "Impossible de récupérer l'agenda : la source pronotepy a échoué" in str(exc_info.value) def test_fetch_homework_ical_mode(mock_fetcher: PronoteFetcher) -> None: """Test la récupération des devoirs en mode source iCal. On mock iCal pour retourner des cours avec blocs de devoirs, et on cible une date. On vérifie que les devoirs sont correctement collectés. :param mock_fetcher: Fetcher de test. :return: None """ target_date = date(2025, 9, 5) start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) lessons = [ Lesson( id="l1", start=start_dt, end=end_dt, subject="Maths", teachers=("Dupont",), rooms=("S1",), group="2ndeA", status=LessonStatus.NORMAL, content=None, homework_blocks=( HomeworkBlock( kind="due", date=target_date, text="Devoir sur les fonctions", html="

Devoir sur les fonctions

", ), ), ) ] with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect, ): m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..." m_parse_ical.return_value = (lessons, [], []) m_collect.return_value = [ Homework( id="hw1", subject="Maths", teachers=(), assigned_on=None, due_on=target_date, text="Devoir sur les fonctions", html="

Devoir sur les fonctions

", ) ] result = mock_fetcher.fetch_homework(target_date) assert len(result) == 1 assert result[0].subject == "Maths" assert result[0].due_on == target_date m_collect.assert_called_once_with(lessons, target_date) def test_fetch_homework_auto_fallback(mock_fetcher: PronoteFetcher) -> None: """Test le mode auto des devoirs : échec iCal, repli pronotepy. On mock iCal pour échouer et pronotepy pour réussir. On vérifie que pronotepy est utilisé. :param mock_fetcher: Fetcher de test. :return: None """ target_date = date(2025, 9, 10) homeworks = [ Homework( id="hw1", subject="Physique", teachers=(), assigned_on=None, due_on=target_date, text="TP à préparer", html="TP à préparer", ) ] with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect, ): m_fetch_ical.side_effect = OSError("iCal unreachable") m_parse_ical.side_effect = OSError("iCal parse error") client = MagicMock() client.get_agenda_fallback.return_value = ([], homeworks) mock_fetcher._pronote_client = client m_collect.return_value = homeworks result = mock_fetcher.fetch_homework(target_date) assert result == homeworks client.get_agenda_fallback.assert_called_once() def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: """Test le mode auto des devoirs : échec des deux sources → PipelineCriticalError. On mock iCal et pronotepy pour échouer. On vérifie la levée de l'erreur critique. :param mock_fetcher: Fetcher de test. :return: None """ target_date = date(2025, 9, 10) with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, ): m_fetch_ical.side_effect = OSError("iCal unreachable") m_parse_ical.side_effect = OSError("iCal parse error") client = MagicMock() mock_fetcher._pronote_client = client with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_homework(target_date) assert "iCal et pronotepy" in str(exc_info.value) def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None: """Test la récupération des messages (toujours via pronotepy). On mock ``get_messages`` du client pour retourner des messages typés. On vérifie que le fetcher retourne ces messages. :param mock_fetcher: Fetcher de test. :return: None """ messages = [ Message( id="m1", type=MessageType.DISCUSSION, title="Devoir de maths", content="À faire pour demain", author="M. Dupont", date=datetime(2025, 9, 1, 10, 0), read=False, ) ] client = MagicMock() client.get_messages.return_value = messages client.get_informations.return_value = [] client.get_agenda_fallback.return_value = ([], []) mock_fetcher._pronote_client = client result = mock_fetcher.fetch_messages() assert result == messages def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None: """Test la récupération des informations (toujours via pronotepy). On mock ``get_informations`` du client pour retourner des informations. On vérifie que le fetcher retourne ces informations. :param mock_fetcher: Fetcher de test. :return: None """ infos = [ Message( id="i1", type=MessageType.INFORMATION, title="Info rentrée", content="Rappel des consignes", author="CPE", date=datetime(2025, 9, 1, 9, 0), read=True, ) ] client = MagicMock() client.get_informations.return_value = infos client.get_messages.return_value = [] client.get_agenda_fallback.return_value = ([], []) mock_fetcher._pronote_client = client result = mock_fetcher.fetch_informations() assert result == infos def test_no_secrets_in_error_messages( mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture ) -> None: """Test que les messages d'erreur ne contiennent pas de secrets. On simule une erreur iCal contenant un token ``icalsecurise`` et on vérifie que le log masqué ne contient pas le token. :param mock_fetcher: Fetcher de test. :param caplog: Fixture pytest pour capturer les logs. :return: None """ with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, ): error_msg = ( "Impossible de lire le fichier iCal file:///ical?icalsecurise=SECRET_TOKEN_123 : " "[Errno 2] No such file or directory" ) m_fetch_ical.side_effect = OSError(error_msg) m_parse_ical.side_effect = OSError("parse error") client = MagicMock() mock_fetcher._pronote_client = client with pytest.raises(PipelineCriticalError): mock_fetcher.fetch_agenda() # Vérifie que le log contient la version masquée assert "SECRET_TOKEN_123" not in caplog.text assert "icalsecurise=REDACTED" in caplog.text or "icalsecurise" not in caplog.text # Ensure trailing newline