Files
college-infos/tests/unit/test_fallback.py
Antoine Van Elstraete 0363898669 feat: authentification QR code / token pour Pronote
Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative
au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect
où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML).

Nouveaux éléments :
- PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr)
- PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json
  (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW)
- PronoteClient._connect_qr_token() : token_login avec creds persistés,
  qrcode_login pour l'enrôlement initial, export_credentials persisté après
  chaque login réussi
- PronoteAuthRotationError : levée en cas d'échec de rotation du token,
  propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à
  PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif)
- _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL
- _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR)
  dans tous les logs du chemin d'authentification

Documentation :
- .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN
- AGENTS.md : contrat d'authentification QR code / token
- Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement

Tests (686 passés, couverture 94.87%) :
- 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation,
  4 tests intégration rotation end-to-end, 4 tests fallback mode-aware
- Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR

Co-authored-by: coder/litellm/coder <coder@agents.invalid>
2026-09-08 23:15:06 +02:00

1485 lines
50 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(
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.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.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.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.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.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.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.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(
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_is_pronotepy_configured_password_mode_all_set() -> None:
"""Test _is_pronotepy_configured() en mode password avec tous les champs définis.
URL, identifiant et mot de passe sont présents : la fonction retourne True.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_password_mode_missing_password() -> None:
"""Test _is_pronotepy_configured() en mode password sans mot de passe.
Le mot de passe est None : la fonction retourne False.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=None,
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is False
def test_is_pronotepy_configured_qr_token_mode_url_only() -> None:
"""Test _is_pronotepy_configured() en mode qr_token avec URL uniquement.
En mode qr_token, seul l'URL est requis : l'identifiant et le mot de
passe peuvent être absents, la fonction retourne True.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username=None,
password=None,
auth_mode="qr_token",
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_qr_token_mode_no_url() -> None:
"""Test _is_pronotepy_configured() en mode qr_token sans URL.
L'URL est None : la fonction retourne False, même si le mode qr_token
ne requiert que PRONOTE_URL.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url=None,
username=None,
password=None,
auth_mode="qr_token",
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is False
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(
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(
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