PRONOTE_ENT était incorrectement traité comme obligatoire pour pronotepy. Rend ENT optionnel pour les connexions directes, conformément à la spécification. Corrige le mode pronotepy explicite et le mode auto sans iCal. 10 tests de régression ajoutés. Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net> Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
1381 lines
47 KiB
Python
1381 lines
47 KiB
Python
"""Tests unitaires pour la logique de repli iCal / pronotepy.
|
|
|
|
Ce module valide le comportement du module :mod:`pronote_sync.sources.pronote.fallback`
|
|
et de son implémentation :class:`PronoteFetcher`. Les tests couvrent :
|
|
|
|
- La sélection de la source d'agenda (``ical``, ``pronotepy``, ``auto``) et de devoirs,
|
|
- Le repli automatique iCal → pronotepy en mode ``auto``,
|
|
- La levée de :class:`PipelineCriticalError` lorsque toutes les sources échouent,
|
|
- Le masquage des secrets dans les messages d'erreur,
|
|
- Les méthodes toujours basées sur pronotepy (messages, informations).
|
|
|
|
Tous les appels réseau et les interactions avec pronotepy sont mockés.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
|
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
|
from pronote_sync.errors import PipelineCriticalError
|
|
from pronote_sync.models.agenda import (
|
|
HomeworkBlock,
|
|
Lesson,
|
|
LessonStatus,
|
|
SchoolEvent,
|
|
SchoolEventKind,
|
|
)
|
|
from pronote_sync.models.homework import Homework
|
|
from pronote_sync.models.message import Message, MessageType
|
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Protocol
|
|
|
|
class _MockPronoteClientProtocol(Protocol):
|
|
def get_messages(self) -> list[Message]: ...
|
|
def get_informations(self) -> list[Message]: ...
|
|
def get_lessons(self, start: date, end: date) -> list[Lesson]: ...
|
|
def get_homeworks(self, start: date, end: date) -> list[Homework]: ...
|
|
|
|
|
|
@pytest.fixture(name="mock_settings")
|
|
def fixture_mock_settings() -> Settings:
|
|
"""Fixture fournissant une configuration Settings adaptée aux tests.
|
|
|
|
:return: Instance de :class:`Settings` avec des valeurs sûres pour les tests.
|
|
:rtype: Settings
|
|
"""
|
|
return Settings(
|
|
pronote=PronoteSettings(
|
|
pronote_url="https://pronote.example.com",
|
|
ical_url=SecretStr("file:///fake/ical.ics"),
|
|
agenda_source="auto",
|
|
homework_source="auto",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent="ent",
|
|
),
|
|
app=Settings().app,
|
|
)
|
|
|
|
|
|
@pytest.fixture(name="mock_fetcher")
|
|
def fixture_mock_fetcher(mock_settings: Settings) -> PronoteFetcher:
|
|
"""Fixture fournissant une instance de :class:`PronoteFetcher` prête à l'emploi.
|
|
|
|
:param mock_settings: Configuration de test.
|
|
:return: Instance de :class:`PronoteFetcher` pour les tests.
|
|
:rtype: PronoteFetcher
|
|
"""
|
|
client: _MockPronoteClientProtocol = MagicMock()
|
|
return PronoteFetcher(settings=mock_settings, pronote_client=client)
|
|
|
|
|
|
def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test la récupération de l'agenda en mode source iCal.
|
|
|
|
On mock ``fetch_ical`` et ``parse_ical`` pour retourner des cours et événements.
|
|
On vérifie que le fetcher retourne bien ces données.
|
|
|
|
: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,
|
|
)
|
|
]
|
|
events = [
|
|
SchoolEvent(
|
|
kind=SchoolEventKind.HOLIDAY,
|
|
label="Vacances",
|
|
from_date=date(2025, 9, 1),
|
|
to_date=date(2025, 9, 15),
|
|
)
|
|
]
|
|
|
|
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 = (lessons, [], events)
|
|
|
|
result_lessons, result_events = mock_fetcher.fetch_agenda()
|
|
|
|
assert result_lessons == lessons
|
|
assert result_events == events
|
|
m_fetch_ical.assert_called_once()
|
|
m_parse_ical.assert_called_once()
|
|
|
|
|
|
def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test la récupération de l'agenda en mode source pronotepy.
|
|
|
|
On mock ``get_lessons`` du client pour retourner des cours.
|
|
On vérifie que le fetcher retourne ces cours (événements scolaires vides).
|
|
|
|
: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="Physique",
|
|
teachers=("Martin",),
|
|
rooms=("Labo1",),
|
|
group="1ereB",
|
|
status=LessonStatus.NORMAL,
|
|
content=None,
|
|
)
|
|
]
|
|
|
|
client = MagicMock()
|
|
client.get_lessons.return_value = lessons
|
|
mock_fetcher._pronote_client = client
|
|
|
|
result_lessons, result_events = mock_fetcher.fetch_agenda()
|
|
|
|
assert result_lessons == lessons
|
|
assert result_events == []
|
|
client.get_lessons.assert_called_once()
|
|
|
|
|
|
def test_fetch_agenda_auto_ical_success(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test le mode auto : succès de l'iCal, pronotepy non appelé.
|
|
|
|
On mock iCal pour réussir, et on vérifie que pronotepy n'est pas sollicité.
|
|
|
|
: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,
|
|
)
|
|
]
|
|
|
|
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 = (lessons, [], [])
|
|
|
|
result_lessons, _ = mock_fetcher.fetch_agenda()
|
|
|
|
assert result_lessons == lessons
|
|
|
|
|
|
def test_fetch_agenda_auto_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test le mode auto : échec iCal, repli sur pronotepy.
|
|
|
|
On mock iCal pour échouer, pronotepy pour réussir. On vérifie que pronotepy est appelé.
|
|
|
|
: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="Histoire",
|
|
teachers=("Lefevre",),
|
|
rooms=("S3",),
|
|
group="2ndeD",
|
|
status=LessonStatus.NORMAL,
|
|
content=None,
|
|
)
|
|
]
|
|
|
|
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.return_value = lessons
|
|
mock_fetcher._pronote_client = client
|
|
|
|
result_lessons, _ = mock_fetcher.fetch_agenda()
|
|
|
|
assert result_lessons == lessons
|
|
client.get_lessons.assert_called_once()
|
|
|
|
|
|
def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test le mode auto : échec des deux sources → PipelineCriticalError.
|
|
|
|
On mock iCal et pronotepy pour échouer. On vérifie la levée de l'erreur critique.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
# Disable pronotepy so fallback is None
|
|
mock_fetcher._settings.pronote.pronote_url = None
|
|
|
|
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()
|
|
mock_fetcher._pronote_client = client
|
|
|
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
|
mock_fetcher.fetch_agenda()
|
|
|
|
assert "la source ical a échoué" in str(exc_info.value)
|
|
|
|
|
|
def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test le mode ical : échec → PipelineCriticalError masquée.
|
|
|
|
On mock iCal pour échouer et désactive pronotepy pour que le repli soit None.
|
|
On vérifie que l'erreur brute est masquée dans la levée.
|
|
|
|
: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"
|
|
mock_fetcher._settings.pronote.pronote_url = None
|
|
|
|
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(
|
|
"Impossible de lire le fichier iCal file:///fake/ical.ics : iCal unreachable"
|
|
)
|
|
m_parse_ical.side_effect = OSError("iCal parse error")
|
|
|
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
|
mock_fetcher.fetch_agenda()
|
|
|
|
assert "Impossible de récupérer l'agenda : la source ical a échoué" in str(exc_info.value)
|
|
# Vérifie que le message ne contient pas de secret
|
|
assert "file:///fake/ical.ics" not in str(exc_info.value)
|
|
|
|
|
|
def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test le mode pronotepy : échec → PipelineCriticalError masquée.
|
|
|
|
On mock pronotepy pour échouer et désactive iCal pour que le repli soit None.
|
|
On vérifie que l'erreur est masquée dans la levée.
|
|
|
|
: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"
|
|
mock_fetcher._settings.pronote.ical_url = None
|
|
|
|
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 "Impossible de récupérer l'agenda : la source pronotepy a échoué" in str(exc_info.value)
|
|
|
|
|
|
def test_fetch_homework_ical_mode(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test la récupération des devoirs en mode source iCal.
|
|
|
|
On mock iCal pour retourner des cours avec blocs de devoirs, et on cible une date.
|
|
On vérifie que les devoirs sont correctement collectés.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
target_date = date(2025, 9, 5)
|
|
|
|
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,
|
|
homework_blocks=(
|
|
HomeworkBlock(
|
|
kind="due",
|
|
date=target_date,
|
|
text="Devoir sur les fonctions",
|
|
html="<p>Devoir sur les fonctions</p>",
|
|
),
|
|
),
|
|
)
|
|
]
|
|
|
|
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 = (lessons, [], [])
|
|
m_collect.return_value = [
|
|
Homework(
|
|
id="hw1",
|
|
subject="Maths",
|
|
teachers=(),
|
|
assigned_on=None,
|
|
due_on=target_date,
|
|
text="Devoir sur les fonctions",
|
|
html="<p>Devoir sur les fonctions</p>",
|
|
)
|
|
]
|
|
|
|
result = mock_fetcher.fetch_homework(target_date)
|
|
|
|
assert len(result) == 1
|
|
assert result[0].subject == "Maths"
|
|
assert result[0].due_on == target_date
|
|
m_collect.assert_called_once_with(lessons, target_date)
|
|
|
|
|
|
def test_fetch_homework_auto_fallback(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test le mode auto des devoirs : échec iCal, repli pronotepy.
|
|
|
|
On mock iCal pour échouer et pronotepy pour réussir. On vérifie que pronotepy est utilisé.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
target_date = date(2025, 9, 10)
|
|
homeworks = [
|
|
Homework(
|
|
id="hw1",
|
|
subject="Physique",
|
|
teachers=(),
|
|
assigned_on=None,
|
|
due_on=target_date,
|
|
text="TP à préparer",
|
|
html="TP à préparer",
|
|
)
|
|
]
|
|
|
|
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.return_value = homeworks
|
|
mock_fetcher._pronote_client = client
|
|
m_collect.return_value = homeworks
|
|
|
|
result = mock_fetcher.fetch_homework(target_date)
|
|
|
|
assert result == homeworks
|
|
client.get_homeworks.assert_called_once()
|
|
|
|
|
|
def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test le mode auto des devoirs : échec des deux sources → PipelineCriticalError.
|
|
|
|
On mock iCal et pronotepy pour échouer. On vérifie la levée de l'erreur critique.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
target_date = date(2025, 9, 10)
|
|
|
|
# Disable pronotepy so fallback is None
|
|
mock_fetcher._settings.pronote.pronote_url = None
|
|
|
|
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()
|
|
mock_fetcher._pronote_client = client
|
|
|
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
|
mock_fetcher.fetch_homework(target_date)
|
|
|
|
assert "la source ical a échoué" in str(exc_info.value)
|
|
|
|
|
|
def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test la récupération des messages (toujours via pronotepy).
|
|
|
|
On mock ``get_messages`` du client pour retourner des messages typés.
|
|
On vérifie que le fetcher retourne ces messages.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
messages = [
|
|
Message(
|
|
id="m1",
|
|
type=MessageType.DISCUSSION,
|
|
title="Devoir de maths",
|
|
content="À faire pour demain",
|
|
author="M. Dupont",
|
|
date=datetime(2025, 9, 1, 10, 0),
|
|
read=False,
|
|
)
|
|
]
|
|
client = MagicMock()
|
|
client.get_messages.return_value = messages
|
|
client.get_informations.return_value = []
|
|
mock_fetcher._pronote_client = client
|
|
|
|
result = mock_fetcher.fetch_messages()
|
|
|
|
assert result == messages
|
|
|
|
|
|
def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None:
|
|
"""Test la récupération des informations (toujours via pronotepy).
|
|
|
|
On mock ``get_informations`` du client pour retourner des informations.
|
|
On vérifie que le fetcher retourne ces informations.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
infos = [
|
|
Message(
|
|
id="i1",
|
|
type=MessageType.INFORMATION,
|
|
title="Info rentrée",
|
|
content="Rappel des consignes",
|
|
author="CPE",
|
|
date=datetime(2025, 9, 1, 9, 0),
|
|
read=True,
|
|
)
|
|
]
|
|
client = MagicMock()
|
|
client.get_informations.return_value = infos
|
|
client.get_messages.return_value = []
|
|
mock_fetcher._pronote_client = client
|
|
|
|
result = mock_fetcher.fetch_informations()
|
|
|
|
assert result == infos
|
|
|
|
|
|
def test_no_secrets_in_error_messages(
|
|
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test que les messages d'erreur ne contiennent pas de secrets.
|
|
|
|
On simule une erreur iCal contenant un token ``icalsecurise`` et on vérifie que le log
|
|
masqué ne contient pas le token.
|
|
|
|
: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
|
|
|
|
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,
|
|
):
|
|
error_msg = (
|
|
"Impossible de lire le fichier iCal file:///ical?icalsecurise=SECRET_TOKEN_123 : "
|
|
"[Errno 2] No such file or directory"
|
|
)
|
|
m_fetch_ical.side_effect = OSError(error_msg)
|
|
m_parse_ical.side_effect = OSError("parse error")
|
|
client = MagicMock()
|
|
mock_fetcher._pronote_client = client
|
|
|
|
with pytest.raises(PipelineCriticalError):
|
|
mock_fetcher.fetch_agenda()
|
|
|
|
# Vérifie que le log contient la version masquée
|
|
assert "SECRET_TOKEN_123" not in caplog.text
|
|
assert "icalsecurise=REDACTED" in caplog.text or "icalsecurise" not in caplog.text
|
|
|
|
|
|
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. 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
|
|
"""
|
|
# Override settings to use ical mode explicitly
|
|
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()
|
|
mock_fetcher._pronote_client = client
|
|
|
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
|
mock_fetcher.fetch_agenda()
|
|
|
|
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_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. 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
|
|
"""
|
|
# Override settings to use pronotepy mode explicitly
|
|
mock_fetcher._settings.pronote.agenda_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_lessons.side_effect = OSError("Pronote API error")
|
|
mock_fetcher._pronote_client = client
|
|
|
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
|
mock_fetcher.fetch_agenda()
|
|
|
|
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:
|
|
"""Test le mode AUTO : aucune source configurée → PipelineCriticalError.
|
|
|
|
On désactive les deux sources. On vérifie que l'erreur critique est levée.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
# Disable both sources
|
|
mock_fetcher._settings.pronote.ical_url = None
|
|
mock_fetcher._settings.pronote.pronote_url = None
|
|
|
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
|
mock_fetcher.fetch_agenda()
|
|
|
|
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.
|
|
|
|
On mock pronotepy pour retourner des devoirs avec différentes dates d'échéance.
|
|
On vérifie que seul le devoir correspondant à la date cible est retourné.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
target_date = date(2025, 9, 10)
|
|
other_date = date(2025, 9, 11)
|
|
|
|
homeworks = [
|
|
Homework(
|
|
id="hw1",
|
|
subject="Maths",
|
|
teachers=(),
|
|
assigned_on=None,
|
|
due_on=target_date,
|
|
text="Devoir pour aujourd'hui",
|
|
html="<p>Devoir pour aujourd'hui</p>",
|
|
),
|
|
Homework(
|
|
id="hw2",
|
|
subject="Physique",
|
|
teachers=(),
|
|
assigned_on=None,
|
|
due_on=other_date,
|
|
text="Devoir pour demain",
|
|
html="<p>Devoir pour demain</p>",
|
|
),
|
|
]
|
|
|
|
client = MagicMock()
|
|
client.get_homeworks.return_value = homeworks
|
|
mock_fetcher._pronote_client = client
|
|
|
|
result = mock_fetcher.fetch_homework(target_date)
|
|
|
|
assert len(result) == 1
|
|
assert result[0].id == "hw1"
|
|
assert result[0].due_on == target_date
|
|
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:
|
|
"""Test que fetch_messages journalise et relance les exceptions.
|
|
|
|
On mock get_messages pour lever une exception contenant une URL secrète.
|
|
On vérifie que l'exception est relancée et que le log masqué ne contient pas l'URL.
|
|
|
|
: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"
|
|
client.get_messages.side_effect = OSError(error_msg)
|
|
mock_fetcher._pronote_client = client
|
|
|
|
with pytest.raises(OSError) as exc_info:
|
|
mock_fetcher.fetch_messages()
|
|
|
|
assert exc_info.value is client.get_messages.side_effect
|
|
assert "SECRET_TOKEN_456" not in caplog.text
|
|
assert (
|
|
"pronote.example.com/messages?token=REDACTED" in caplog.text
|
|
or "pronote.example.com/messages" in caplog.text
|
|
)
|
|
|
|
|
|
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
|
|
:rtype: 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
|
|
:rtype: 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 en mode explicite ical.
|
|
|
|
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"
|
|
|
|
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()
|
|
|
|
# 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_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 = "auto"
|
|
|
|
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_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 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 is None
|
|
|
|
# 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_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 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 is None
|
|
|
|
# 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
|
|
:rtype: 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
|
|
:rtype: 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_agenda_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
|
|
mock_fetcher: PronoteFetcher,
|
|
) -> None:
|
|
"""Test le mode auto : iCal configuré mais échoue, repli sur pronotepy sans ent.
|
|
|
|
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
|
|
et que le résultat provient de pronotepy, pas une erreur.
|
|
|
|
: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 auto mode with ical_url configured but ent=None
|
|
mock_fetcher._settings.pronote.agenda_source = "auto"
|
|
mock_fetcher._settings.pronote.ent = None # Explicitly None
|
|
|
|
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.return_value = lessons
|
|
mock_fetcher._pronote_client = client
|
|
|
|
result_lessons, result_events = mock_fetcher.fetch_agenda()
|
|
|
|
assert result_lessons == lessons
|
|
assert result_events == []
|
|
client.get_lessons.assert_called_once()
|
|
|
|
|
|
def test_fetch_homework_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
|
|
mock_fetcher: PronoteFetcher,
|
|
) -> None:
|
|
"""Test le mode auto des devoirs : iCal configuré mais échoue, repli sur pronotepy sans ent.
|
|
|
|
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
|
|
et que le résultat provient de pronotepy, pas une erreur.
|
|
|
|
:param mock_fetcher: Fetcher de test.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
target_date = date(2025, 9, 10)
|
|
homeworks = [
|
|
Homework(
|
|
id="hw1",
|
|
subject="Physique",
|
|
teachers=(),
|
|
assigned_on=None,
|
|
due_on=target_date,
|
|
text="TP à préparer",
|
|
html="TP à préparer",
|
|
)
|
|
]
|
|
|
|
# Override settings to use auto mode with ical_url configured but ent=None
|
|
mock_fetcher._settings.pronote.homework_source = "auto"
|
|
mock_fetcher._settings.pronote.ent = None # Explicitly None
|
|
|
|
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.return_value = homeworks
|
|
mock_fetcher._pronote_client = client
|
|
m_collect.return_value = homeworks
|
|
|
|
result = mock_fetcher.fetch_homework(target_date)
|
|
|
|
assert result == homeworks
|
|
client.get_homeworks.assert_called_once()
|
|
|
|
|
|
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
|
|
mock_fetcher: PronoteFetcher,
|
|
) -> None:
|
|
"""Test que fetch_homework lève PipelineCriticalError en mode explicite ical.
|
|
|
|
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"
|
|
|
|
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)
|
|
|
|
# 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_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 est 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 = "auto"
|
|
|
|
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
|
|
:rtype: 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
|
|
)
|
|
|
|
|
|
def test_is_pronotepy_configured_without_ent_returns_true() -> None:
|
|
"""Test _is_pronotepy_configured() retourne True quand ent est None.
|
|
|
|
Les autres champs de la configuration pronotepy sont présents, donc la
|
|
fonction renvoie True.
|
|
|
|
Ce test valide que PRONOTE_ENT est optionnel pour pronotepy.
|
|
|
|
:return: None
|
|
"""
|
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
|
|
|
# Créer des settings avec pronotepy configuré mais sans ent
|
|
settings = Settings(
|
|
pronote=PronoteSettings(
|
|
pronote_url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None, # Explicitement None
|
|
agenda_source="pronotepy",
|
|
homework_source="pronotepy",
|
|
),
|
|
app=Settings().app,
|
|
)
|
|
|
|
client: _MockPronoteClientProtocol = MagicMock()
|
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
|
|
|
# Should return True even without ent
|
|
assert fetcher._is_pronotepy_configured() is True
|
|
|
|
|
|
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
|
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
|
|
|
Ce test valide que le mode auto peut utiliser pronotepy même sans ent configuré.
|
|
|
|
:return: None
|
|
"""
|
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
|
|
|
settings = Settings(
|
|
pronote=PronoteSettings(
|
|
pronote_url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None, # Explicitement None
|
|
ical_url=None, # Pas de iCal URL
|
|
agenda_source="auto",
|
|
),
|
|
app=Settings().app,
|
|
)
|
|
|
|
client: _MockPronoteClientProtocol = MagicMock()
|
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
|
|
|
primary, fallback = fetcher._agenda_sources()
|
|
|
|
assert primary == "pronotepy"
|
|
assert fallback is None
|
|
|
|
|
|
def test_homework_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
|
"""Test _homework_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
|
|
|
Ce test valide que le mode auto peut utiliser pronotepy pour les devoirs même sans ent configuré.
|
|
|
|
:return: None
|
|
"""
|
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
|
|
|
settings = Settings(
|
|
pronote=PronoteSettings(
|
|
pronote_url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None, # Explicitement None
|
|
ical_url=None, # Pas de iCal URL
|
|
homework_source="auto",
|
|
),
|
|
app=Settings().app,
|
|
)
|
|
|
|
client: _MockPronoteClientProtocol = MagicMock()
|
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
|
|
|
primary, fallback = fetcher._homework_sources()
|
|
|
|
assert primary == "pronotepy"
|
|
assert fallback is None
|
|
|
|
|
|
# Ensure trailing newline
|