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:
@@ -4,9 +4,9 @@ Ce module fournit l'enum :class:`AgendaSource`, le protocole
|
||||
:class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la
|
||||
classe :class:`PronoteFetcher` qui sélectionne la source selon la
|
||||
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``).
|
||||
La logique applique un repli **unique** : une source primaire est
|
||||
essayée en premier et, en cas d'échec, une seule source de repli (jamais
|
||||
réciproque ni itératif) est essayée si elle est configurée. Les messages
|
||||
Contrat strict : les modes explicites n'utilisent que la source
|
||||
configurée, sans aucun repli ; seul le mode ``auto`` applique un repli
|
||||
unique iCal → pronotepy, en cas d'exception uniquement. Les messages
|
||||
et informations proviennent toujours de pronotepy. Toutes les erreurs
|
||||
sont journalisées avec des secrets masqués via
|
||||
:func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions
|
||||
@@ -82,13 +82,14 @@ class PronoteFetcherProtocol(Protocol):
|
||||
|
||||
|
||||
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
|
||||
(``agenda_source`` / ``homework_source``) : la source primaire est
|
||||
essayée en premier et, si elle échoue, une seule source de repli est
|
||||
essayée lorsqu'elle est configurée. Les messages et informations
|
||||
proviennent toujours de pronotepy.
|
||||
(``agenda_source`` / ``homework_source``) : les modes explicites
|
||||
n'utilisent que la source configurée, sans aucun repli ; seul le mode
|
||||
``auto`` essaie une source primaire puis, si elle échoue, une seule
|
||||
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:
|
||||
@@ -171,11 +172,10 @@ class PronoteFetcher:
|
||||
def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]:
|
||||
"""Sélectionne la source primaire et le repli unique pour l'agenda.
|
||||
|
||||
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré
|
||||
(repli pronotepy si la configuration pronotepy est complète),
|
||||
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et
|
||||
``PRONOTEPY`` désignent la source primaire et tentent l'autre
|
||||
source en repli unique si elle est configurée.
|
||||
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
|
||||
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
|
||||
primaire si ``ical_url`` est configuré (repli pronotepy si la
|
||||
configuration pronotepy est complète), sinon pronotepy sans repli.
|
||||
|
||||
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
|
||||
:rtype: tuple[_SourceName, _SourceName | None]
|
||||
@@ -183,9 +183,9 @@ class PronoteFetcher:
|
||||
"""
|
||||
source = AgendaSource(self._settings.pronote.agenda_source)
|
||||
if source is AgendaSource.ICAL:
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
return "ical", None
|
||||
if source is AgendaSource.PRONOTEPY:
|
||||
return "pronotepy", "ical" if self._is_ical_configured() else None
|
||||
return "pronotepy", None
|
||||
if self._is_ical_configured():
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
if self._is_pronotepy_configured():
|
||||
@@ -208,11 +208,13 @@ class PronoteFetcher:
|
||||
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||
"""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
|
||||
source de repli unique (l'autre source, si configurée) est
|
||||
essayée. Si la source primaire et le repli échouent — ou si
|
||||
aucune source n'est configurée en mode ``AUTO`` — une erreur
|
||||
critique est levée.
|
||||
En mode explicite (``ical`` ou ``pronotepy``), la source désignée
|
||||
est la seule tentée : si elle échoue, une erreur critique est levée
|
||||
sans repli. En mode ``auto``, la source primaire est essayée en
|
||||
premier puis, si elle échoue, la source de repli unique (l'autre
|
||||
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)``.
|
||||
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
||||
@@ -289,11 +291,10 @@ class PronoteFetcher:
|
||||
def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]:
|
||||
"""Sélectionne la source primaire et le repli unique pour les devoirs.
|
||||
|
||||
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré
|
||||
(repli pronotepy si la configuration pronotepy est complète),
|
||||
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et
|
||||
``PRONOTEPY`` désignent la source primaire et tentent l'autre
|
||||
source en repli unique si elle est configurée.
|
||||
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
|
||||
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
|
||||
primaire si ``ical_url`` est configuré (repli pronotepy si la
|
||||
configuration pronotepy est complète), sinon pronotepy sans repli.
|
||||
|
||||
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
|
||||
:rtype: tuple[_SourceName, _SourceName | None]
|
||||
@@ -301,9 +302,9 @@ class PronoteFetcher:
|
||||
"""
|
||||
source = AgendaSource(self._settings.pronote.homework_source)
|
||||
if source is AgendaSource.ICAL:
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
return "ical", None
|
||||
if source is AgendaSource.PRONOTEPY:
|
||||
return "pronotepy", "ical" if self._is_ical_configured() else None
|
||||
return "pronotepy", None
|
||||
if self._is_ical_configured():
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
if self._is_pronotepy_configured():
|
||||
@@ -327,11 +328,13 @@ class PronoteFetcher:
|
||||
def fetch_homework(self, target_date: date) -> list[Homework]:
|
||||
"""Récupère les devoirs selon la source configurée.
|
||||
|
||||
La source primaire est essayée en premier ; si elle échoue, la
|
||||
source de repli unique (l'autre source, si configurée) est
|
||||
essayée. Si la source primaire et le repli échouent — ou si
|
||||
aucune source n'est configurée en mode ``AUTO`` — une erreur
|
||||
critique est levée.
|
||||
En mode explicite (``ical`` ou ``pronotepy``), la source désignée
|
||||
est la seule tentée : si elle échoue, une erreur critique est levée
|
||||
sans repli. En mode ``auto``, la source primaire est essayée en
|
||||
premier puis, si elle échoue, la source de repli unique (l'autre
|
||||
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.
|
||||
:return: Liste des devoirs.
|
||||
|
||||
@@ -85,6 +85,7 @@ def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None:
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
start_dt = datetime(2025, 9, 1, 8, 0)
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
start_dt = datetime(2025, 9, 1, 8, 0)
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
start_dt = datetime(2025, 9, 1, 8, 0)
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
start_dt = datetime(2025, 9, 1, 8, 0)
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
# Disable pronotepy so fallback is 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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
# Override settings to use ical mode explicitly and disable fallback
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
# Override settings to use pronotepy mode explicitly and disable fallback
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
target_date = date(2025, 9, 10)
|
||||
homeworks = [
|
||||
@@ -424,6 +433,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
target_date = date(2025, 9, 10)
|
||||
|
||||
@@ -453,6 +463,7 @@ def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
messages = [
|
||||
Message(
|
||||
@@ -483,6 +494,7 @@ def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None:
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
infos = [
|
||||
Message(
|
||||
@@ -516,6 +528,7 @@ def test_no_secrets_in_error_messages(
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
|
||||
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
|
||||
|
||||
|
||||
def test_fetch_agenda_ical_mode_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -> None:
|
||||
"""Test le mode ICAL : échec iCal, repli sur pronotepy.
|
||||
def test_fetch_agenda_ical_mode_does_not_fallback_to_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.
|
||||
: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
|
||||
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_parse_ical.side_effect = OSError("iCal parse error")
|
||||
client = MagicMock()
|
||||
client.get_lessons.return_value = lessons
|
||||
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 result_events == []
|
||||
client.get_lessons.assert_called_once()
|
||||
assert "la source ical a échoué" in str(exc_info.value)
|
||||
# Vérifie que pronotepy n'a PAS été appelé
|
||||
client.get_lessons.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetcher) -> None:
|
||||
"""Test le mode PRONOTEPY : échec pronotepy, repli sur iCal.
|
||||
def test_fetch_agenda_pronotepy_mode_does_not_fallback_to_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.
|
||||
: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
|
||||
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.get_lessons.side_effect = OSError("Pronote API error")
|
||||
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 result_events == []
|
||||
m_fetch_ical.assert_called_once()
|
||||
m_parse_ical.assert_called_once()
|
||||
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_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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
# Disable both sources
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
target_date = date(2025, 9, 10)
|
||||
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()
|
||||
|
||||
|
||||
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(
|
||||
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -704,6 +884,7 @@ def test_fetch_messages_logs_and_re_raises(
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
client = MagicMock()
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
mock_fetcher._settings.pronote.ical_url = None
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
mock_fetcher._settings.pronote.agenda_source = "auto"
|
||||
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(
|
||||
mock_fetcher: PronoteFetcher,
|
||||
) -> 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 vérifie que l'erreur PipelineCriticalError est levée.
|
||||
On mock ICAL pour échouer. On vérifie que l'erreur critique est levée
|
||||
et que pronotepy n'est PAS appelé (pas de repli en mode explicite).
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
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:
|
||||
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
|
||||
) -> 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.
|
||||
Ce test utilise le mode AUTO pour tester le comportement de repli.
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
mock_fetcher._settings.pronote.agenda_source = "ical"
|
||||
mock_fetcher._settings.pronote.agenda_source = "auto"
|
||||
|
||||
with (
|
||||
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
|
||||
|
||||
|
||||
def test_homework_sources_explicit_ical_mode(mock_fetcher: PronoteFetcher) -> None:
|
||||
"""Test _homework_sources en mode ICAL.
|
||||
def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher) -> None:
|
||||
"""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é,
|
||||
ou None sinon.
|
||||
On vérifie que la source primaire est ical et que le repli est TOUJOURS None,
|
||||
même si pronotepy est configuré.
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
mock_fetcher._settings.pronote.homework_source = "ical"
|
||||
|
||||
# With pronotepy configured
|
||||
primary, fallback = mock_fetcher._homework_sources()
|
||||
assert primary == "ical"
|
||||
assert fallback == "pronotepy"
|
||||
assert fallback is None
|
||||
|
||||
# Without pronotepy configured
|
||||
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
|
||||
|
||||
|
||||
def test_homework_sources_explicit_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None:
|
||||
"""Test _homework_sources en mode PRONOTEPY.
|
||||
def test_homework_sources_explicit_pronotepy_mode_strict(
|
||||
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é,
|
||||
ou None sinon.
|
||||
On vérifie que la source primaire est pronotepy et que le repli est TOUJOURS None,
|
||||
même si iCal est configuré.
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
mock_fetcher._settings.pronote.homework_source = "pronotepy"
|
||||
|
||||
# With ical configured
|
||||
primary, fallback = mock_fetcher._homework_sources()
|
||||
assert primary == "pronotepy"
|
||||
assert fallback == "ical"
|
||||
assert fallback is None
|
||||
|
||||
# Without ical configured
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
mock_fetcher._settings.pronote.homework_source = "auto"
|
||||
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.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
mock_fetcher._settings.pronote.homework_source = "auto"
|
||||
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(
|
||||
mock_fetcher: PronoteFetcher,
|
||||
) -> 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 vérifie que l'erreur PipelineCriticalError est levée.
|
||||
On mock ICAL pour échouer. On vérifie que l'erreur critique est levée
|
||||
et que pronotepy n'est PAS appelé (pas de repli en mode explicite).
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
target_date = date(2025, 9, 10)
|
||||
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:
|
||||
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
|
||||
) -> 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.
|
||||
Ce test utilise le mode AUTO pour tester le comportement de repli.
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
target_date = date(2025, 9, 10)
|
||||
mock_fetcher._settings.pronote.homework_source = "ical"
|
||||
mock_fetcher._settings.pronote.homework_source = "auto"
|
||||
|
||||
with (
|
||||
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 caplog: Fixture pytest pour capturer les logs.
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
client = MagicMock()
|
||||
error_msg = "Erreur Pronote : impossible de récupérer les informations https://pronote.example.com/infos?token=SECRET_TOKEN_789"
|
||||
|
||||
Reference in New Issue
Block a user