diff --git a/pronote_sync/sources/pronote/fallback.py b/pronote_sync/sources/pronote/fallback.py index 693288b..a3e441f 100644 --- a/pronote_sync/sources/pronote/fallback.py +++ b/pronote_sync/sources/pronote/fallback.py @@ -4,9 +4,9 @@ Ce module fournit l'enum :class:`AgendaSource`, le protocole :class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la classe :class:`PronoteFetcher` qui sélectionne la source selon la configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``). -La logique applique un repli **unique** : une source primaire est -essayée en premier et, en cas d'échec, une seule source de repli (jamais -réciproque ni itératif) est essayée si elle est configurée. Les messages +Contrat strict : les modes explicites n'utilisent que la source +configurée, sans aucun repli ; seul le mode ``auto`` applique un repli +unique iCal → pronotepy, en cas d'exception uniquement. Les messages et informations proviennent toujours de pronotepy. Toutes les erreurs sont journalisées avec des secrets masqués via :func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions @@ -82,13 +82,14 @@ class PronoteFetcherProtocol(Protocol): class PronoteFetcher: - """Récupère les données Pronote via iCal ou pronotepy avec repli unique. + """Récupère les données Pronote via iCal ou pronotepy, repli réservé au mode ``auto``. Unifie les sources iCal et pronotepy selon la source configurée - (``agenda_source`` / ``homework_source``) : la source primaire est - essayée en premier et, si elle échoue, une seule source de repli est - essayée lorsqu'elle est configurée. Les messages et informations - proviennent toujours de pronotepy. + (``agenda_source`` / ``homework_source``) : les modes explicites + n'utilisent que la source configurée, sans aucun repli ; seul le mode + ``auto`` essaie une source primaire puis, si elle échoue, une seule + source de repli lorsqu'elle est configurée. Les messages et + informations proviennent toujours de pronotepy. """ def __init__(self, settings: Settings, pronote_client: PronoteClientProtocol) -> None: @@ -171,11 +172,10 @@ class PronoteFetcher: def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]: """Sélectionne la source primaire et le repli unique pour l'agenda. - En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré - (repli pronotepy si la configuration pronotepy est complète), - sinon pronotepy sans repli. Les modes explicites ``ICAL`` et - ``PRONOTEPY`` désignent la source primaire et tentent l'autre - source en repli unique si elle est configurée. + Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule + source utilisée, sans aucun repli. En mode ``AUTO``, iCal est + primaire si ``ical_url`` est configuré (repli pronotepy si la + configuration pronotepy est complète), sinon pronotepy sans repli. :return: Tuple ``(source primaire, source de repli ou ``None``)``. :rtype: tuple[_SourceName, _SourceName | None] @@ -183,9 +183,9 @@ class PronoteFetcher: """ source = AgendaSource(self._settings.pronote.agenda_source) if source is AgendaSource.ICAL: - return "ical", "pronotepy" if self._is_pronotepy_configured() else None + return "ical", None if source is AgendaSource.PRONOTEPY: - return "pronotepy", "ical" if self._is_ical_configured() else None + return "pronotepy", None if self._is_ical_configured(): return "ical", "pronotepy" if self._is_pronotepy_configured() else None if self._is_pronotepy_configured(): @@ -208,11 +208,13 @@ class PronoteFetcher: def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]: """Récupère les cours et les événements scolaires selon la source configurée. - La source primaire est essayée en premier ; si elle échoue, la - source de repli unique (l'autre source, si configurée) est - essayée. Si la source primaire et le repli échouent — ou si - aucune source n'est configurée en mode ``AUTO`` — une erreur - critique est levée. + En mode explicite (``ical`` ou ``pronotepy``), la source désignée + est la seule tentée : si elle échoue, une erreur critique est levée + sans repli. En mode ``auto``, la source primaire est essayée en + premier puis, si elle échoue, la source de repli unique (l'autre + source, si configurée) l'est à son tour ; si la source primaire et + le repli échouent — ou si aucune source n'est configurée — une + erreur critique est levée. :return: Tuple ``(cours, événements scolaires)``. :rtype: tuple[list[Lesson], list[SchoolEvent]] @@ -289,11 +291,10 @@ class PronoteFetcher: def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]: """Sélectionne la source primaire et le repli unique pour les devoirs. - En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré - (repli pronotepy si la configuration pronotepy est complète), - sinon pronotepy sans repli. Les modes explicites ``ICAL`` et - ``PRONOTEPY`` désignent la source primaire et tentent l'autre - source en repli unique si elle est configurée. + Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule + source utilisée, sans aucun repli. En mode ``AUTO``, iCal est + primaire si ``ical_url`` est configuré (repli pronotepy si la + configuration pronotepy est complète), sinon pronotepy sans repli. :return: Tuple ``(source primaire, source de repli ou ``None``)``. :rtype: tuple[_SourceName, _SourceName | None] @@ -301,9 +302,9 @@ class PronoteFetcher: """ source = AgendaSource(self._settings.pronote.homework_source) if source is AgendaSource.ICAL: - return "ical", "pronotepy" if self._is_pronotepy_configured() else None + return "ical", None if source is AgendaSource.PRONOTEPY: - return "pronotepy", "ical" if self._is_ical_configured() else None + return "pronotepy", None if self._is_ical_configured(): return "ical", "pronotepy" if self._is_pronotepy_configured() else None if self._is_pronotepy_configured(): @@ -327,11 +328,13 @@ class PronoteFetcher: def fetch_homework(self, target_date: date) -> list[Homework]: """Récupère les devoirs selon la source configurée. - La source primaire est essayée en premier ; si elle échoue, la - source de repli unique (l'autre source, si configurée) est - essayée. Si la source primaire et le repli échouent — ou si - aucune source n'est configurée en mode ``AUTO`` — une erreur - critique est levée. + En mode explicite (``ical`` ou ``pronotepy``), la source désignée + est la seule tentée : si elle échoue, une erreur critique est levée + sans repli. En mode ``auto``, la source primaire est essayée en + premier puis, si elle échoue, la source de repli unique (l'autre + source, si configurée) l'est à son tour ; si la source primaire et + le repli échouent — ou si aucune source n'est configurée — une + erreur critique est levée. :param target_date: Date cible pour laquelle collecter les devoirs. :return: Liste des devoirs. diff --git a/tests/unit/test_fallback.py b/tests/unit/test_fallback.py index 57c502b..209fde2 100644 --- a/tests/unit/test_fallback.py +++ b/tests/unit/test_fallback.py @@ -85,6 +85,7 @@ def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) @@ -133,6 +134,7 @@ def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) @@ -168,6 +170,7 @@ def test_fetch_agenda_auto_ical_success(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) @@ -204,6 +207,7 @@ def test_fetch_agenda_auto_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) - :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ start_dt = datetime(2025, 9, 1, 8, 0) end_dt = datetime(2025, 9, 1, 9, 30) @@ -244,6 +248,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ # Disable pronotepy so fallback is None mock_fetcher._settings.pronote.pronote_url = None @@ -271,6 +276,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ # Override settings to use ical mode explicitly and disable fallback mock_fetcher._settings.pronote.agenda_source = "ical" @@ -301,6 +307,7 @@ def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> No :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ # Override settings to use pronotepy mode explicitly and disable fallback mock_fetcher._settings.pronote.agenda_source = "pronotepy" @@ -324,6 +331,7 @@ def test_fetch_homework_ical_mode(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ target_date = date(2025, 9, 5) @@ -385,6 +393,7 @@ def test_fetch_homework_auto_fallback(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ target_date = date(2025, 9, 10) homeworks = [ @@ -424,6 +433,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ target_date = date(2025, 9, 10) @@ -453,6 +463,7 @@ def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ messages = [ Message( @@ -483,6 +494,7 @@ def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None: :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ infos = [ Message( @@ -516,6 +528,7 @@ def test_no_secrets_in_error_messages( :param mock_fetcher: Fetcher de test. :param caplog: Fixture pytest pour capturer les logs. :return: None + :rtype: None """ # Disable pronotepy so fallback is None to trigger PipelineCriticalError mock_fetcher._settings.pronote.pronote_url = None @@ -541,30 +554,18 @@ def test_no_secrets_in_error_messages( 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. +def test_fetch_agenda_ical_mode_does_not_fallback_to_pronotepy( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode ICAL : échec iCal, PAS de repli sur pronotepy. - On mock iCal pour échouer, pronotepy pour réussir. On vérifie que pronotepy est appelé. + On mock iCal pour échouer. On vérifie que pronotepy n'est PAS appelé + et qu'une PipelineCriticalError est levée. :param mock_fetcher: Fetcher de test. :return: None + :rtype: 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" @@ -575,40 +576,28 @@ def test_fetch_agenda_ical_mode_fallback_to_pronotepy(mock_fetcher: PronoteFetch 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() + with pytest.raises(PipelineCriticalError) as exc_info: + mock_fetcher.fetch_agenda() - assert result_lessons == lessons - assert result_events == [] - client.get_lessons.assert_called_once() + assert "la source ical a échoué" in str(exc_info.value) + # Vérifie que pronotepy n'a PAS été appelé + client.get_lessons.assert_not_called() -def test_fetch_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetcher) -> None: - """Test le mode PRONOTEPY : échec pronotepy, repli sur iCal. +def test_fetch_agenda_pronotepy_mode_does_not_fallback_to_ical( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode PRONOTEPY : échec pronotepy, PAS de repli sur iCal. - On mock pronotepy pour échouer, iCal pour réussir. On vérifie que iCal est appelé. + On mock pronotepy pour échouer. On vérifie que iCal n'est PAS appelé + et qu'une PipelineCriticalError est levée. :param mock_fetcher: Fetcher de test. :return: None + :rtype: 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" @@ -619,15 +608,14 @@ def test_fetch_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetch 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() + with pytest.raises(PipelineCriticalError) as exc_info: + mock_fetcher.fetch_agenda() - assert result_lessons == lessons - assert result_events == [] - m_fetch_ical.assert_called_once() - m_parse_ical.assert_called_once() + assert "la source pronotepy a échoué" in str(exc_info.value) + # Vérifie que iCal n'a PAS été appelé + m_fetch_ical.assert_not_called() + m_parse_ical.assert_not_called() def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher) -> None: @@ -637,6 +625,7 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher) :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ # Disable both sources mock_fetcher._settings.pronote.ical_url = None @@ -648,6 +637,130 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher) assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value) +def test_fetch_agenda_ical_mode_empty_list_is_success(mock_fetcher: PronoteFetcher) -> None: + """Test le mode ICAL : liste vide = succès valide, sans repli. + + On mock iCal pour retourner une liste vide. On vérifie que le résultat + est ([], []) et qu'aucun repli n'est tenté. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + 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.return_value = "BEGIN:VCALENDAR\n..." + m_parse_ical.return_value = ([], [], []) + client = MagicMock() + mock_fetcher._pronote_client = client + + result_lessons, result_events = mock_fetcher.fetch_agenda() + + assert result_lessons == [] + assert result_events == [] + # Vérifie que pronotepy n'a PAS été appelé + client.get_lessons.assert_not_called() + + +def test_fetch_agenda_pronotepy_mode_empty_list_is_success( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode PRONOTEPY : liste vide = succès valide, sans repli. + + On mock pronotepy pour retourner une liste vide. On vérifie que le résultat + est ([], []) et qu'aucun repli n'est tenté. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + mock_fetcher._settings.pronote.agenda_source = "pronotepy" + + client = MagicMock() + client.get_lessons.return_value = [] + mock_fetcher._pronote_client = client + + 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, + ): + result_lessons, result_events = mock_fetcher.fetch_agenda() + + assert result_lessons == [] + assert result_events == [] + # Vérifie que iCal n'a PAS été appelé + m_fetch_ical.assert_not_called() + m_parse_ical.assert_not_called() + + +def test_fetch_homework_ical_mode_empty_list_is_success( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode ICAL des devoirs : liste vide = succès valide, sans repli. + + On mock iCal pour retourner une liste vide. On vérifie que le résultat + est [] et qu'aucun repli n'est tenté. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + target_date = date(2025, 9, 10) + mock_fetcher._settings.pronote.homework_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, + patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect, + ): + m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..." + m_parse_ical.return_value = ([], [], []) + m_collect.return_value = [] + client = MagicMock() + mock_fetcher._pronote_client = client + + result = mock_fetcher.fetch_homework(target_date) + + assert result == [] + # Vérifie que pronotepy n'a PAS été appelé + client.get_homeworks.assert_not_called() + + +def test_fetch_homework_pronotepy_mode_empty_list_is_success( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode PRONOTEPY des devoirs : liste vide = succès valide, sans repli. + + On mock pronotepy pour retourner une liste vide. On vérifie que le résultat + est [] et qu'aucun repli n'est tenté. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + target_date = date(2025, 9, 10) + mock_fetcher._settings.pronote.homework_source = "pronotepy" + + client = MagicMock() + client.get_homeworks.return_value = [] + mock_fetcher._pronote_client = client + + 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, + ): + result = mock_fetcher.fetch_homework(target_date) + + assert result == [] + # Vérifie que iCal n'a PAS été appelé + m_fetch_ical.assert_not_called() + m_parse_ical.assert_not_called() + + 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. @@ -656,6 +769,7 @@ def test_fetch_homework_filters_by_target_date(mock_fetcher: PronoteFetcher) -> :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ target_date = date(2025, 9, 10) other_date = date(2025, 9, 11) @@ -693,6 +807,72 @@ def test_fetch_homework_filters_by_target_date(mock_fetcher: PronoteFetcher) -> client.get_homeworks.assert_called_once() +def test_fetch_homework_ical_mode_does_not_fallback_to_pronotepy( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode ICAL des devoirs : échec iCal, PAS de repli sur pronotepy. + + On mock iCal pour échouer. On vérifie que pronotepy n'est PAS appelé + et qu'une PipelineCriticalError est levée. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + target_date = date(2025, 9, 10) + mock_fetcher._settings.pronote.homework_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, + 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() + mock_fetcher._pronote_client = client + m_collect.side_effect = OSError("collect error") + + with pytest.raises(PipelineCriticalError) as exc_info: + mock_fetcher.fetch_homework(target_date) + + assert "la source ical a échoué" in str(exc_info.value) + # Vérifie que pronotepy n'a PAS été appelé + client.get_homeworks.assert_not_called() + + +def test_fetch_homework_pronotepy_mode_does_not_fallback_to_ical( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode PRONOTEPY des devoirs : échec pronotepy, PAS de repli sur iCal. + + On mock pronotepy pour échouer. On vérifie que iCal n'est PAS appelé + et qu'une PipelineCriticalError est levée. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + target_date = date(2025, 9, 10) + mock_fetcher._settings.pronote.homework_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_homeworks.side_effect = OSError("Pronote API error") + mock_fetcher._pronote_client = client + + with pytest.raises(PipelineCriticalError) as exc_info: + mock_fetcher.fetch_homework(target_date) + + assert "la source pronotepy a échoué" in str(exc_info.value) + # Vérifie que iCal n'a PAS été appelé + m_fetch_ical.assert_not_called() + m_parse_ical.assert_not_called() + + def test_fetch_messages_logs_and_re_raises( mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture ) -> None: @@ -704,6 +884,7 @@ def test_fetch_messages_logs_and_re_raises( :param mock_fetcher: Fetcher de test. :param caplog: Fixture pytest pour capturer les logs. :return: None + :rtype: None """ client = MagicMock() error_msg = "Erreur Pronote : impossible de récupérer les messages https://pronote.example.com/messages?token=SECRET_TOKEN_456" @@ -729,6 +910,7 @@ def test_fetch_agenda_ical_url_none_raises_value_error(mock_fetcher: PronoteFetc :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ mock_fetcher._settings.pronote.ical_url = None with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"): @@ -743,6 +925,7 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ mock_fetcher._settings.pronote.agenda_source = "auto" mock_fetcher._settings.pronote.ical_url = None @@ -754,13 +937,14 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc def test_fetch_agenda_fallback_both_fail_raises_pipeline_critical_error( mock_fetcher: PronoteFetcher, ) -> None: - """Test que fetch_agenda lève PipelineCriticalError si ICAL échoue et pronotepy aussi. + """Test que fetch_agenda lève PipelineCriticalError en mode explicite ical. - On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec. - On vérifie que l'erreur PipelineCriticalError est levée. + On mock ICAL pour échouer. On vérifie que l'erreur critique est levée + et que pronotepy n'est PAS appelé (pas de repli en mode explicite). :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ mock_fetcher._settings.pronote.agenda_source = "ical" @@ -777,21 +961,26 @@ def test_fetch_agenda_fallback_both_fail_raises_pipeline_critical_error( with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_agenda() - assert "les sources ical et pronotepy ont échoué" in str(exc_info.value) + # En mode explicite, seul l'échec de la source primaire est rapporté + assert "la source ical a échoué" in str(exc_info.value) + # Vérifie que pronotepy n'a PAS été appelé (pas de repli en mode explicite) + client.get_lessons.assert_not_called() -def test_fetch_agenda_fallback_returns_empty_logs_warning( +def test_fetch_agenda_auto_fallback_returns_empty_logs_warning( mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture ) -> None: """Test que fetch_agenda retourne ([], []) et journalise un avertissement si le repli retourne vide. On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log. + Ce test utilise le mode AUTO pour tester le comportement de repli. :param mock_fetcher: Fetcher de test. :param caplog: Fixture pytest pour capturer les logs. :return: None + :rtype: None """ - mock_fetcher._settings.pronote.agenda_source = "ical" + mock_fetcher._settings.pronote.agenda_source = "auto" with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, @@ -813,21 +1002,22 @@ def test_fetch_agenda_fallback_returns_empty_logs_warning( assert "a retourné un résultat vide après l'échec de ical" in caplog.text -def test_homework_sources_explicit_ical_mode(mock_fetcher: PronoteFetcher) -> None: - """Test _homework_sources en mode ICAL. +def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher) -> None: + """Test _homework_sources en mode ICAL : strict, sans repli. - On vérifie que la source primaire est ical et que le repli est pronotepy si configuré, - ou None sinon. + On vérifie que la source primaire est ical et que le repli est TOUJOURS None, + même si pronotepy est configuré. :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ mock_fetcher._settings.pronote.homework_source = "ical" # With pronotepy configured primary, fallback = mock_fetcher._homework_sources() assert primary == "ical" - assert fallback == "pronotepy" + assert fallback is None # Without pronotepy configured mock_fetcher._settings.pronote.pronote_url = None @@ -836,21 +1026,24 @@ def test_homework_sources_explicit_ical_mode(mock_fetcher: PronoteFetcher) -> No assert fallback is None -def test_homework_sources_explicit_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None: - """Test _homework_sources en mode PRONOTEPY. +def test_homework_sources_explicit_pronotepy_mode_strict( + mock_fetcher: PronoteFetcher, +) -> None: + """Test _homework_sources en mode PRONOTEPY : strict, sans repli. - On vérifie que la source primaire est pronotepy et que le repli est ical si configuré, - ou None sinon. + On vérifie que la source primaire est pronotepy et que le repli est TOUJOURS None, + même si iCal est configuré. :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ mock_fetcher._settings.pronote.homework_source = "pronotepy" # With ical configured primary, fallback = mock_fetcher._homework_sources() assert primary == "pronotepy" - assert fallback == "ical" + assert fallback is None # Without ical configured mock_fetcher._settings.pronote.ical_url = None @@ -867,6 +1060,7 @@ def test_homework_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFe :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ mock_fetcher._settings.pronote.homework_source = "auto" mock_fetcher._settings.pronote.ical_url = None @@ -882,6 +1076,7 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ mock_fetcher._settings.pronote.homework_source = "auto" mock_fetcher._settings.pronote.ical_url = None @@ -896,13 +1091,14 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error( mock_fetcher: PronoteFetcher, ) -> None: - """Test que fetch_homework lève PipelineCriticalError si ICAL échoue et pronotepy aussi. + """Test que fetch_homework lève PipelineCriticalError en mode explicite ical. - On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec. - On vérifie que l'erreur PipelineCriticalError est levée. + On mock ICAL pour échouer. On vérifie que l'erreur critique est levée + et que pronotepy n'est PAS appelé (pas de repli en mode explicite). :param mock_fetcher: Fetcher de test. :return: None + :rtype: None """ target_date = date(2025, 9, 10) mock_fetcher._settings.pronote.homework_source = "ical" @@ -922,22 +1118,27 @@ def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error( with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_homework(target_date) - assert "les sources ical et pronotepy ont échoué" in str(exc_info.value) + # En mode explicite, seul l'échec de la source primaire est rapporté + assert "la source ical a échoué" in str(exc_info.value) + # Vérifie que pronotepy n'a PAS été appelé (pas de repli en mode explicite) + client.get_homeworks.assert_not_called() -def test_fetch_homework_fallback_returns_empty_logs_warning( +def test_fetch_homework_auto_fallback_returns_empty_logs_warning( mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture ) -> None: """Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide. On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log. + Ce test utilise le mode AUTO pour tester le comportement de repli. :param mock_fetcher: Fetcher de test. :param caplog: Fixture pytest pour capturer les logs. :return: None + :rtype: None """ target_date = date(2025, 9, 10) - mock_fetcher._settings.pronote.homework_source = "ical" + mock_fetcher._settings.pronote.homework_source = "auto" with ( patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, @@ -967,6 +1168,7 @@ def test_fetch_informations_logs_and_re_raises_secret( :param mock_fetcher: Fetcher de test. :param caplog: Fixture pytest pour capturer les logs. :return: None + :rtype: None """ client = MagicMock() error_msg = "Erreur Pronote : impossible de récupérer les informations https://pronote.example.com/infos?token=SECRET_TOKEN_789"