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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user