"""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_lessons(self, start: date, end: date) -> list[Lesson]: ... def get_homeworks(self, start: date, end: date) -> 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( pronote_url="https://pronote.example.com", 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_lessons`` 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_lessons.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_lessons.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_lessons.return_value = lessons mock_fetcher._pronote_client = client result_lessons, _ = mock_fetcher.fetch_agenda() assert result_lessons == lessons client.get_lessons.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 """ # Disable pronotepy so fallback is None mock_fetcher._settings.pronote.pronote_url = 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 "la source ical a échoué" 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 et désactive pronotepy pour que le repli soit None. 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 and disable fallback mock_fetcher._settings.pronote.agenda_source = "ical" mock_fetcher._settings.pronote.pronote_url = 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( "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 et désactive iCal pour que le repli soit None. 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 and disable fallback mock_fetcher._settings.pronote.agenda_source = "pronotepy" mock_fetcher._settings.pronote.ical_url = None client = MagicMock() client.get_lessons.side_effect = OSError("Pronote API error") 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_homeworks.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_homeworks.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) # Disable pronotepy so fallback is None mock_fetcher._settings.pronote.pronote_url = 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_homework(target_date) assert "la source ical a échoué" 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 = [] 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 = [] 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 """ # Disable pronotepy so fallback is None to trigger PipelineCriticalError mock_fetcher._settings.pronote.pronote_url = 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 def test_fetch_agenda_ical_mode_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -> None: """Test le mode ICAL : é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="Maths", teachers=("Dupont",), rooms=("S1",), group="2ndeA", status=LessonStatus.NORMAL, content=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("iCal unreachable") m_parse_ical.side_effect = OSError("iCal parse error") client = MagicMock() client.get_lessons.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_lessons.assert_called_once() def test_fetch_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetcher) -> None: """Test le mode PRONOTEPY : échec pronotepy, repli sur iCal. On mock pronotepy pour échouer, iCal pour réussir. On vérifie que iCal 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="SVT", teachers=("Durand",), rooms=("S2",), group="3emeC", status=LessonStatus.NORMAL, content=None, ) ] # Override settings to use pronotepy mode explicitly mock_fetcher._settings.pronote.agenda_source = "pronotepy" 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, ): client = MagicMock() client.get_lessons.side_effect = OSError("Pronote API error") mock_fetcher._pronote_client = client m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..." m_parse_ical.return_value = (lessons, [], []) result_lessons, result_events = mock_fetcher.fetch_agenda() assert result_lessons == lessons assert result_events == [] m_fetch_ical.assert_called_once() m_parse_ical.assert_called_once() def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher) -> None: """Test le mode AUTO : aucune source configurée → PipelineCriticalError. On désactive les deux sources. On vérifie que l'erreur critique est levée. :param mock_fetcher: Fetcher de test. :return: None """ # Disable both sources mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.pronote_url = None with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_agenda() assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value) def test_fetch_homework_filters_by_target_date(mock_fetcher: PronoteFetcher) -> None: """Test que les devoirs sont filtrés par date cible en mode pronotepy. On mock pronotepy pour retourner des devoirs avec différentes dates d'échéance. On vérifie que seul le devoir correspondant à la date cible est retourné. :param mock_fetcher: Fetcher de test. :return: None """ target_date = date(2025, 9, 10) other_date = date(2025, 9, 11) homeworks = [ Homework( id="hw1", subject="Maths", teachers=(), assigned_on=None, due_on=target_date, text="Devoir pour aujourd'hui", html="Devoir pour aujourd'hui
", ), Homework( id="hw2", subject="Physique", teachers=(), assigned_on=None, due_on=other_date, text="Devoir pour demain", html="Devoir pour demain
", ), ] client = MagicMock() client.get_homeworks.return_value = homeworks mock_fetcher._pronote_client = client result = mock_fetcher.fetch_homework(target_date) assert len(result) == 1 assert result[0].id == "hw1" assert result[0].due_on == target_date client.get_homeworks.assert_called_once() def test_fetch_messages_logs_and_re_raises( mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture ) -> None: """Test que fetch_messages journalise et relance les exceptions. On mock get_messages pour lever une exception contenant une URL secrète. On vérifie que l'exception est relancée et que le log masqué ne contient pas l'URL. :param mock_fetcher: Fetcher de test. :param caplog: Fixture pytest pour capturer les logs. :return: None """ client = MagicMock() error_msg = "Erreur Pronote : impossible de récupérer les messages https://pronote.example.com/messages?token=SECRET_TOKEN_456" client.get_messages.side_effect = OSError(error_msg) mock_fetcher._pronote_client = client with pytest.raises(OSError) as exc_info: mock_fetcher.fetch_messages() assert exc_info.value is client.get_messages.side_effect assert "SECRET_TOKEN_456" not in caplog.text assert ( "pronote.example.com/messages?token=REDACTED" in caplog.text or "pronote.example.com/messages" in caplog.text ) # Ensure trailing newline