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