test+docs(M4): tests get_lessons/get_homeworks + repli unique + doc sync
Tests: - test_pronote_client : get_lessons/get_homeworks success + error propagation, ENT resolution, factory parent/student, mode dégradé messages/informations (ConnectionError, TimeoutError) - test_fallback : repli unique ICAL→pronotepy et PRONOTEPY→iCal, filtre target_date, fetch_messages log + re-raise sans secrets, aucune source configurée → PipelineCriticalError - 59 tests passent (16 client + 17 fallback + 26 iCal) Documentation: - TODO.md : 7 items M4 cochés, contrat repli corrigé (unique, non réciproque) selon décision D4 - GUIDE_DEV_PYTHON.md : get_agenda_fallback → get_lessons/get_homeworks, pronote_url/account_type ajoutés au modèle + env vars + .env.example, AgendaSource StrEnum, §5.1.8 fallback contract mis à jour (repli unique) Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
@@ -40,9 +40,8 @@ if TYPE_CHECKING:
|
||||
class _MockPronoteClientProtocol(Protocol):
|
||||
def get_messages(self) -> list[Message]: ...
|
||||
def get_informations(self) -> list[Message]: ...
|
||||
def get_agenda_fallback(
|
||||
self, start: date, end: date
|
||||
) -> tuple[list[Lesson], list[Homework]]: ...
|
||||
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")
|
||||
@@ -54,6 +53,7 @@ def fixture_mock_settings() -> Settings:
|
||||
"""
|
||||
return Settings(
|
||||
pronote=PronoteSettings(
|
||||
pronote_url="https://pronote.example.com",
|
||||
ical_url=SecretStr("file:///fake/ical.ics"),
|
||||
agenda_source="auto",
|
||||
homework_source="auto",
|
||||
@@ -128,7 +128,7 @@ def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None:
|
||||
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_agenda_fallback`` du client pour retourner des cours.
|
||||
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.
|
||||
@@ -151,14 +151,14 @@ def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None:
|
||||
]
|
||||
|
||||
client = MagicMock()
|
||||
client.get_agenda_fallback.return_value = (lessons, [])
|
||||
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_agenda_fallback.assert_called_once()
|
||||
client.get_lessons.assert_called_once()
|
||||
|
||||
|
||||
def test_fetch_agenda_auto_ical_success(mock_fetcher: PronoteFetcher) -> None:
|
||||
@@ -228,13 +228,13 @@ def test_fetch_agenda_auto_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -
|
||||
m_fetch_ical.side_effect = OSError("iCal unreachable")
|
||||
m_parse_ical.side_effect = OSError("iCal parse error")
|
||||
client = MagicMock()
|
||||
client.get_agenda_fallback.return_value = (lessons, [])
|
||||
client.get_lessons.return_value = lessons
|
||||
mock_fetcher._pronote_client = client
|
||||
|
||||
result_lessons, _ = mock_fetcher.fetch_agenda()
|
||||
|
||||
assert result_lessons == lessons
|
||||
client.get_agenda_fallback.assert_called_once()
|
||||
client.get_lessons.assert_called_once()
|
||||
|
||||
|
||||
def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
||||
@@ -245,6 +245,9 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: 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,
|
||||
@@ -257,19 +260,21 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
||||
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||
mock_fetcher.fetch_agenda()
|
||||
|
||||
assert "iCal et pronotepy" in str(exc_info.value)
|
||||
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. On vérifie que l'erreur brute est masquée dans la levé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
|
||||
"""
|
||||
# Override settings to use ical mode explicitly
|
||||
# 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,
|
||||
@@ -283,7 +288,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -291,15 +296,18 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
|
||||
def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> None:
|
||||
"""Test le mode pronotepy : échec → PipelineCriticalError masquée.
|
||||
|
||||
On mock pronotepy pour échouer. On vérifie que l'erreur est masquée dans la levé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
|
||||
"""
|
||||
# Override settings to use pronotepy mode explicitly
|
||||
# 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:
|
||||
@@ -399,14 +407,14 @@ def test_fetch_homework_auto_fallback(mock_fetcher: PronoteFetcher) -> None:
|
||||
m_fetch_ical.side_effect = OSError("iCal unreachable")
|
||||
m_parse_ical.side_effect = OSError("iCal parse error")
|
||||
client = MagicMock()
|
||||
client.get_agenda_fallback.return_value = ([], homeworks)
|
||||
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_agenda_fallback.assert_called_once()
|
||||
client.get_homeworks.assert_called_once()
|
||||
|
||||
|
||||
def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
||||
@@ -419,6 +427,9 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> 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,
|
||||
@@ -431,7 +442,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
||||
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||
mock_fetcher.fetch_homework(target_date)
|
||||
|
||||
assert "iCal et pronotepy" in str(exc_info.value)
|
||||
assert "la source ical a échoué" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
|
||||
@@ -457,7 +468,6 @@ def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
|
||||
client = MagicMock()
|
||||
client.get_messages.return_value = messages
|
||||
client.get_informations.return_value = []
|
||||
client.get_agenda_fallback.return_value = ([], [])
|
||||
mock_fetcher._pronote_client = client
|
||||
|
||||
result = mock_fetcher.fetch_messages()
|
||||
@@ -488,7 +498,6 @@ def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None:
|
||||
client = MagicMock()
|
||||
client.get_informations.return_value = infos
|
||||
client.get_messages.return_value = []
|
||||
client.get_agenda_fallback.return_value = ([], [])
|
||||
mock_fetcher._pronote_client = client
|
||||
|
||||
result = mock_fetcher.fetch_informations()
|
||||
@@ -508,6 +517,9 @@ def test_no_secrets_in_error_messages(
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
:return: 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,
|
||||
@@ -529,4 +541,184 @@ 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.
|
||||
|
||||
On mock iCal pour échouer, pronotepy pour réussir. On vérifie que pronotepy est appelé.
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: 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"
|
||||
|
||||
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_agenda_pronotepy_mode_fallback_to_ical(mock_fetcher: PronoteFetcher) -> None:
|
||||
"""Test le mode PRONOTEPY : échec pronotepy, repli sur iCal.
|
||||
|
||||
On mock pronotepy pour échouer, iCal pour réussir. On vérifie que iCal est appelé.
|
||||
|
||||
:param mock_fetcher: Fetcher de test.
|
||||
:return: 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"
|
||||
|
||||
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
|
||||
m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..."
|
||||
m_parse_ical.return_value = (lessons, [], [])
|
||||
|
||||
result_lessons, result_events = mock_fetcher.fetch_agenda()
|
||||
|
||||
assert result_lessons == lessons
|
||||
assert result_events == []
|
||||
m_fetch_ical.assert_called_once()
|
||||
m_parse_ical.assert_called_once()
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
# 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_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
|
||||
"""
|
||||
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_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
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
|
||||
Reference in New Issue
Block a user