fix(M4): modes explicites ical/pronotepy stricts — suppression du repli réciproque

Les modes explicites (ical, pronotepy) ne basculent plus silencieusement vers
l'autre source en cas d'échec. Seul le mode auto conserve le repli iCal →
pronotepy sur exception. Conforme au contrat de TODO.md M4 et AGENTS.md.

- fallback.py : _agenda_sources() et _homework_sources() retournent
  (source, None) en mode explicite ; docstrings mises à jour.
- test_fallback.py : 4 tests remplacés, 4 tests ajoutés (non-repli +
  empty-list-is-success), 4 tests ajustés ; :rtype: None ajouté.

Co-authored-by: coder/coder coder@agents.invalid
Co-authored-by: test-engineer/test-engineer test-engineer@agents.invalid
This commit is contained in:
2026-09-06 17:42:04 +02:00
parent 8b50731bab
commit 16f9b57dc2
2 changed files with 309 additions and 104 deletions

View File

@@ -4,9 +4,9 @@ Ce module fournit l'enum :class:`AgendaSource`, le protocole
:class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la :class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la
classe :class:`PronoteFetcher` qui sélectionne la source selon la classe :class:`PronoteFetcher` qui sélectionne la source selon la
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``). configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``).
La logique applique un repli **unique** : une source primaire est Contrat strict : les modes explicites n'utilisent que la source
essayée en premier et, en cas d'échec, une seule source de repli (jamais configurée, sans aucun repli ; seul le mode ``auto`` applique un repli
réciproque ni itératif) est essayée si elle est configurée. Les messages unique iCal → pronotepy, en cas d'exception uniquement. Les messages
et informations proviennent toujours de pronotepy. Toutes les erreurs et informations proviennent toujours de pronotepy. Toutes les erreurs
sont journalisées avec des secrets masqués via sont journalisées avec des secrets masqués via
:func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions :func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions
@@ -82,13 +82,14 @@ class PronoteFetcherProtocol(Protocol):
class PronoteFetcher: 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 Unifie les sources iCal et pronotepy selon la source configurée
(``agenda_source`` / ``homework_source``) : la source primaire est (``agenda_source`` / ``homework_source``) : les modes explicites
essayée en premier et, si elle échoue, une seule source de repli est n'utilisent que la source configurée, sans aucun repli ; seul le mode
essayée lorsqu'elle est configurée. Les messages et informations ``auto`` essaie une source primaire puis, si elle échoue, une seule
proviennent toujours de pronotepy. 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: def __init__(self, settings: Settings, pronote_client: PronoteClientProtocol) -> None:
@@ -171,11 +172,10 @@ class PronoteFetcher:
def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]: def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour l'agenda. """Sélectionne la source primaire et le repli unique pour l'agenda.
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
(repli pronotepy si la configuration pronotepy est complète), source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et primaire si ``ical_url`` est configuré (repli pronotepy si la
``PRONOTEPY`` désignent la source primaire et tentent l'autre configuration pronotepy est complète), sinon pronotepy sans repli.
source en repli unique si elle est configurée.
:return: Tuple ``(source primaire, source de repli ou ``None``)``. :return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None] :rtype: tuple[_SourceName, _SourceName | None]
@@ -183,9 +183,9 @@ class PronoteFetcher:
""" """
source = AgendaSource(self._settings.pronote.agenda_source) source = AgendaSource(self._settings.pronote.agenda_source)
if source is AgendaSource.ICAL: if source is AgendaSource.ICAL:
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", None
if source is AgendaSource.PRONOTEPY: if source is AgendaSource.PRONOTEPY:
return "pronotepy", "ical" if self._is_ical_configured() else None return "pronotepy", None
if self._is_ical_configured(): if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured(): if self._is_pronotepy_configured():
@@ -208,11 +208,13 @@ class PronoteFetcher:
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]: def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère les cours et les événements scolaires selon la source configurée. """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 En mode explicite (``ical`` ou ``pronotepy``), la source désignée
source de repli unique (l'autre source, si configurée) est est la seule tentée : si elle échoue, une erreur critique est levée
essayée. Si la source primaire et le repli échouent — ou si sans repli. En mode ``auto``, la source primaire est essayée en
aucune source n'est configurée en mode ``AUTO`` — une erreur premier puis, si elle échoue, la source de repli unique (l'autre
critique est levée. 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)``. :return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]] :rtype: tuple[list[Lesson], list[SchoolEvent]]
@@ -289,11 +291,10 @@ class PronoteFetcher:
def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]: def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour les devoirs. """Sélectionne la source primaire et le repli unique pour les devoirs.
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
(repli pronotepy si la configuration pronotepy est complète), source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et primaire si ``ical_url`` est configuré (repli pronotepy si la
``PRONOTEPY`` désignent la source primaire et tentent l'autre configuration pronotepy est complète), sinon pronotepy sans repli.
source en repli unique si elle est configurée.
:return: Tuple ``(source primaire, source de repli ou ``None``)``. :return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None] :rtype: tuple[_SourceName, _SourceName | None]
@@ -301,9 +302,9 @@ class PronoteFetcher:
""" """
source = AgendaSource(self._settings.pronote.homework_source) source = AgendaSource(self._settings.pronote.homework_source)
if source is AgendaSource.ICAL: if source is AgendaSource.ICAL:
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", None
if source is AgendaSource.PRONOTEPY: if source is AgendaSource.PRONOTEPY:
return "pronotepy", "ical" if self._is_ical_configured() else None return "pronotepy", None
if self._is_ical_configured(): if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured(): if self._is_pronotepy_configured():
@@ -327,11 +328,13 @@ class PronoteFetcher:
def fetch_homework(self, target_date: date) -> list[Homework]: def fetch_homework(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs selon la source configurée. """Récupère les devoirs selon la source configurée.
La source primaire est essayée en premier ; si elle échoue, la En mode explicite (``ical`` ou ``pronotepy``), la source désignée
source de repli unique (l'autre source, si configurée) est est la seule tentée : si elle échoue, une erreur critique est levée
essayée. Si la source primaire et le repli échouent — ou si sans repli. En mode ``auto``, la source primaire est essayée en
aucune source n'est configurée en mode ``AUTO`` — une erreur premier puis, si elle échoue, la source de repli unique (l'autre
critique est levée. 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. :param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs. :return: Liste des devoirs.

View File

@@ -85,6 +85,7 @@ def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
start_dt = datetime(2025, 9, 1, 8, 0) start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30) 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Disable pronotepy so fallback is None # Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.pronote_url = 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Override settings to use ical mode explicitly and disable fallback # Override settings to use ical mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "ical" 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Override settings to use pronotepy mode explicitly and disable fallback # Override settings to use pronotepy mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "pronotepy" 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 5) 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
homeworks = [ homeworks = [
@@ -424,6 +433,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
@@ -453,6 +463,7 @@ def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
messages = [ messages = [
Message( Message(
@@ -483,6 +494,7 @@ def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None:
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
infos = [ infos = [
Message( Message(
@@ -516,6 +528,7 @@ def test_no_secrets_in_error_messages(
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
# Disable pronotepy so fallback is None to trigger PipelineCriticalError # Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.pronote_url = None 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 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: def test_fetch_agenda_ical_mode_does_not_fallback_to_pronotepy(
"""Test le mode ICAL : échec iCal, repli sur 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. :param mock_fetcher: Fetcher de test.
:return: None :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 # Override settings to use ical mode explicitly
mock_fetcher._settings.pronote.agenda_source = "ical" 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_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error") m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock() client = MagicMock()
client.get_lessons.return_value = lessons
mock_fetcher._pronote_client = client 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 "la source ical a échoué" in str(exc_info.value)
assert result_events == [] # Vérifie que pronotepy n'a PAS été appelé
client.get_lessons.assert_called_once() client.get_lessons.assert_not_called()
def test_fetch_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetcher) -> None: def test_fetch_agenda_pronotepy_mode_does_not_fallback_to_ical(
"""Test le mode PRONOTEPY : échec pronotepy, repli sur 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. :param mock_fetcher: Fetcher de test.
:return: None :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 # Override settings to use pronotepy mode explicitly
mock_fetcher._settings.pronote.agenda_source = "pronotepy" 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 = MagicMock()
client.get_lessons.side_effect = OSError("Pronote API error") client.get_lessons.side_effect = OSError("Pronote API error")
mock_fetcher._pronote_client = client 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 "la source pronotepy a échoué" in str(exc_info.value)
assert result_events == [] # Vérifie que iCal n'a PAS été appelé
m_fetch_ical.assert_called_once() m_fetch_ical.assert_not_called()
m_parse_ical.assert_called_once() m_parse_ical.assert_not_called()
def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher) -> None: 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
# Disable both sources # Disable both sources
mock_fetcher._settings.pronote.ical_url = None 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) 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: 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. """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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
other_date = date(2025, 9, 11) 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() 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( def test_fetch_messages_logs_and_re_raises(
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -704,6 +884,7 @@ def test_fetch_messages_logs_and_re_raises(
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
client = MagicMock() client = MagicMock()
error_msg = "Erreur Pronote : impossible de récupérer les messages https://pronote.example.com/messages?token=SECRET_TOKEN_456" 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_url = None
with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"): 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.agenda_source = "auto" mock_fetcher._settings.pronote.agenda_source = "auto"
mock_fetcher._settings.pronote.ical_url = None 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( def test_fetch_agenda_fallback_both_fail_raises_pipeline_critical_error(
mock_fetcher: PronoteFetcher, mock_fetcher: PronoteFetcher,
) -> None: ) -> 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 mock ICAL pour échouer. On vérifie que l'erreur critique est levée
On vérifie que l'erreur PipelineCriticalError est levée. et que pronotepy n'est PAS appelé (pas de repli en mode explicite).
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.agenda_source = "ical" 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: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda() 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 mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test que fetch_agenda retourne ([], []) et journalise un avertissement si le repli retourne vide. """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. 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 mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.agenda_source = "ical" mock_fetcher._settings.pronote.agenda_source = "auto"
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, 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 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: def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher) -> None:
"""Test _homework_sources en mode ICAL. """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é, On vérifie que la source primaire est ical et que le repli est TOUJOURS None,
ou None sinon. même si pronotepy est configuré.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "ical" mock_fetcher._settings.pronote.homework_source = "ical"
# With pronotepy configured # With pronotepy configured
primary, fallback = mock_fetcher._homework_sources() primary, fallback = mock_fetcher._homework_sources()
assert primary == "ical" assert primary == "ical"
assert fallback == "pronotepy" assert fallback is None
# Without pronotepy configured # Without pronotepy configured
mock_fetcher._settings.pronote.pronote_url = None 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 assert fallback is None
def test_homework_sources_explicit_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None: def test_homework_sources_explicit_pronotepy_mode_strict(
"""Test _homework_sources en mode PRONOTEPY. 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é, On vérifie que la source primaire est pronotepy et que le repli est TOUJOURS None,
ou None sinon. même si iCal est configuré.
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "pronotepy" mock_fetcher._settings.pronote.homework_source = "pronotepy"
# With ical configured # With ical configured
primary, fallback = mock_fetcher._homework_sources() primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy" assert primary == "pronotepy"
assert fallback == "ical" assert fallback is None
# Without ical configured # Without ical configured
mock_fetcher._settings.pronote.ical_url = None 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "auto" mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "auto" mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None 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( def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
mock_fetcher: PronoteFetcher, mock_fetcher: PronoteFetcher,
) -> None: ) -> 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 mock ICAL pour échouer. On vérifie que l'erreur critique est levée
On vérifie que l'erreur PipelineCriticalError est levée. et que pronotepy n'est PAS appelé (pas de repli en mode explicite).
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "ical" 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: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_homework(target_date) 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 mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide. """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. 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 mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
mock_fetcher._settings.pronote.homework_source = "ical" mock_fetcher._settings.pronote.homework_source = "auto"
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, 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 mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
:rtype: None
""" """
client = MagicMock() client = MagicMock()
error_msg = "Erreur Pronote : impossible de récupérer les informations https://pronote.example.com/infos?token=SECRET_TOKEN_789" error_msg = "Erreur Pronote : impossible de récupérer les informations https://pronote.example.com/infos?token=SECRET_TOKEN_789"