From bb1f90bf5fa4140b5b0a67a038a45ef41dfe71ed Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sun, 6 Sep 2026 15:39:07 +0200 Subject: [PATCH] =?UTF-8?q?test(M4):=20couverture=20fallback.py=2081?= =?UTF-8?q?=E2=86=9298%=20(11=20tests=20cibl=C3=A9s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _fetch_agenda_ical sans ical_url → ValueError - _agenda_sources AUTO pronotepy seul, _homework_sources modes explicites + AUTO + aucune source configurée - fetch_agenda/fetch_homework : repli échoue → PipelineCriticalError, repli retourne [] → warning d'ambiguité - fetch_informations : log redact_exception + re-raise sans secrets - Couverture pronote_sync/sources/pronote/ : 95% (≥90% requis) Co-authored-by: opencode/test-engineer --- tests/unit/test_fallback.py | 263 ++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/tests/unit/test_fallback.py b/tests/unit/test_fallback.py index 4ed65d8..57c502b 100644 --- a/tests/unit/test_fallback.py +++ b/tests/unit/test_fallback.py @@ -721,4 +721,267 @@ def test_fetch_messages_logs_and_re_raises( ) +def test_fetch_agenda_ical_url_none_raises_value_error(mock_fetcher: PronoteFetcher) -> None: + """Test que _fetch_agenda_ical lève ValueError si ical_url est None. + + On désactive ical_url et on appelle _fetch_agenda_ical(). On vérifie que + l'erreur ValueError est levée. + + :param mock_fetcher: Fetcher de test. + :return: None + """ + mock_fetcher._settings.pronote.ical_url = None + with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"): + mock_fetcher._fetch_agenda_ical() + + +def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetcher) -> None: + """Test _agenda_sources en mode AUTO avec seulement pronotepy configuré. + + On désactive ical_url mais on garde pronotepy configuré. On vérifie que la + source primaire est pronotepy et qu'il n'y a pas de repli. + + :param mock_fetcher: Fetcher de test. + :return: None + """ + mock_fetcher._settings.pronote.agenda_source = "auto" + mock_fetcher._settings.pronote.ical_url = None + primary, fallback = mock_fetcher._agenda_sources() + assert primary == "pronotepy" + assert fallback is None + + +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. + + On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec. + On vérifie que l'erreur PipelineCriticalError est levée. + + :param mock_fetcher: Fetcher de test. + :return: 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.side_effect = OSError("iCal unreachable") + m_parse_ical.side_effect = OSError("iCal parse error") + 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 "les sources ical et pronotepy ont échoué" in str(exc_info.value) + + +def test_fetch_agenda_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. + + :param mock_fetcher: Fetcher de test. + :param caplog: Fixture pytest pour capturer les logs. + :return: 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, + patch( + "pronote_sync.sources.pronote.fallback.PronoteFetcher._fetch_agenda_pronotepy" + ) as m_fetch_pronotepy, + ): + 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_fetch_pronotepy.return_value = ([], []) # Empty result from fallback + + lessons, events = mock_fetcher.fetch_agenda() + + assert lessons == [] + assert events == [] + 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. + + On vérifie que la source primaire est ical et que le repli est pronotepy si configuré, + ou None sinon. + + :param mock_fetcher: Fetcher de test. + :return: None + """ + mock_fetcher._settings.pronote.homework_source = "ical" + + # With pronotepy configured + primary, fallback = mock_fetcher._homework_sources() + assert primary == "ical" + assert fallback == "pronotepy" + + # Without pronotepy configured + mock_fetcher._settings.pronote.pronote_url = None + primary, fallback = mock_fetcher._homework_sources() + assert primary == "ical" + assert fallback is None + + +def test_homework_sources_explicit_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None: + """Test _homework_sources en mode PRONOTEPY. + + On vérifie que la source primaire est pronotepy et que le repli est ical si configuré, + ou None sinon. + + :param mock_fetcher: Fetcher de test. + :return: None + """ + mock_fetcher._settings.pronote.homework_source = "pronotepy" + + # With ical configured + primary, fallback = mock_fetcher._homework_sources() + assert primary == "pronotepy" + assert fallback == "ical" + + # Without ical configured + mock_fetcher._settings.pronote.ical_url = None + primary, fallback = mock_fetcher._homework_sources() + assert primary == "pronotepy" + assert fallback is None + + +def test_homework_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetcher) -> None: + """Test _homework_sources en mode AUTO avec seulement pronotepy configuré. + + On désactive ical_url mais on garde pronotepy configuré. On vérifie que la + source primaire est pronotepy et qu'il n'y a pas de repli. + + :param mock_fetcher: Fetcher de test. + :return: None + """ + mock_fetcher._settings.pronote.homework_source = "auto" + mock_fetcher._settings.pronote.ical_url = None + primary, fallback = mock_fetcher._homework_sources() + assert primary == "pronotepy" + assert fallback is None + + +def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: PronoteFetcher) -> None: + """Test _homework_sources en mode AUTO avec aucune source configurée. + + On désactive les deux sources. On vérifie que l'erreur PipelineCriticalError est levée. + + :param mock_fetcher: Fetcher de test. + :return: None + """ + mock_fetcher._settings.pronote.homework_source = "auto" + mock_fetcher._settings.pronote.ical_url = None + mock_fetcher._settings.pronote.pronote_url = None + + with pytest.raises(PipelineCriticalError) as exc_info: + mock_fetcher._homework_sources() + + assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value) + + +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. + + On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec. + On vérifie que l'erreur PipelineCriticalError est levée. + + :param mock_fetcher: Fetcher de test. + :return: 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() + client.get_homeworks.side_effect = OSError("Pronote API error") + 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 "les sources ical et pronotepy ont échoué" in str(exc_info.value) + + +def test_fetch_homework_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. + + :param mock_fetcher: Fetcher de test. + :param caplog: Fixture pytest pour capturer les logs. + :return: 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.return_value = [] # Empty result + + result = mock_fetcher.fetch_homework(target_date) + + assert result == [] + assert "a retourné un résultat vide après l'échec de ical" in caplog.text + + +def test_fetch_informations_logs_and_re_raises_secret( + mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture +) -> None: + """Test que fetch_informations journalise et relance les exceptions avec secret masqué. + + On mock get_informations pour lever une exception contenant une URL secrète. + On vérifie que l'exception est relancée et que le log ne contient pas le secret. + + :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 informations https://pronote.example.com/infos?token=SECRET_TOKEN_789" + client.get_informations.side_effect = OSError(error_msg) + mock_fetcher._pronote_client = client + + with pytest.raises(OSError) as exc_info: + mock_fetcher.fetch_informations() + + assert exc_info.value is client.get_informations.side_effect + assert "SECRET_TOKEN_789" not in caplog.text + assert ( + "pronote.example.com/infos?token=REDACTED" in caplog.text + or "pronote.example.com/infos" in caplog.text + ) + + # Ensure trailing newline