test(M4): couverture fallback.py 81→98% (11 tests ciblés)

- _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 <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 15:39:07 +02:00
parent 9d9a55ed40
commit bb1f90bf5f

View File

@@ -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