``).
+ :return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
+ :rtype: tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]
+ """
+ content: str | None = None
+ due_blocks: list[tuple[date, str]] = []
+ assigned_blocks: list[tuple[date, str]] = []
+
+ content_match = _CONTENT_PATTERN.search(body)
+ if content_match is not None:
+ content = _strip_html(content_match.group(1))
+
+ for match in _DUE_PATTERN.finditer(body):
+ due_date = _parse_french_date(match.group(1))
+ if due_date is not None:
+ due_blocks.append((due_date, _strip_html(match.group(2))))
+
+ for match in _ASSIGNED_PATTERN.finditer(body):
+ assigned_date = _parse_french_date(match.group(1))
+ if assigned_date is not None:
+ assigned_blocks.append((assigned_date, _strip_html(match.group(2))))
+
+ return content, due_blocks, assigned_blocks
+
+
+def parse_homework_blocks(
+ due_blocks: list[tuple[date, str]],
+ assigned_blocks: list[tuple[date, str]],
+) -> tuple[HomeworkBlock, ...]:
+ """Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
+
+ Les blocs dus (``kind="due"``) précèdent les blocs donnés
+ (``kind="assigned"``), dans l'ordre des listes. Tous les blocs
+ sont préservés, y compris lorsque plusieurs partagent la même date.
+
+ :param due_blocks: Liste de tuples ``(date, texte)`` des devoirs à faire.
+ :param assigned_blocks: Liste de tuples ``(date, texte)`` des devoirs donnés.
+ :return: Tuple de blocs de devoirs pour le cours.
+ :rtype: tuple[HomeworkBlock, ...]
+ """
+ blocks: list[HomeworkBlock] = []
+ for due_date, text in due_blocks:
+ blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text))
+ for assigned_date, text in assigned_blocks:
+ blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text))
+ return tuple(blocks)
+
+
+def normalize_homework_text(text: str) -> str:
+ """Normalise le texte d'un devoir pour la déduplication.
+
+ Unifie les espaces multiples, supprime les balises HTML, puis
+ applique trim et minuscules.
+
+ :param text: Texte brut du devoir.
+ :return: Texte normalisé.
+ :rtype: str
+ """
+ normalized = re.sub(r"\s+", " ", text)
+ normalized = _TAG_PATTERN.sub("", normalized)
+ return normalized.strip().lower()
+
+
+def generate_homework_id(due_on: date, normalized_text: str) -> str:
+ """Génère un ID stable pour un devoir.
+
+ L'ID est la clé ``AAAA-MM-JJ|texte_normalisé`` hachée en SHA-1 dont
+ on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
+ pas utilisé à des fins de sécurité (``usedforsecurity=False``).
+
+ :param due_on: Date d'échéance du devoir.
+ :param normalized_text: Texte normalisé du devoir.
+ :return: ID stable (12 caractères hexadécimaux).
+ :rtype: str
+ """
+ payload = f"{due_on.isoformat()}|{normalized_text}".encode()
+ return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
+
+
+def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework]:
+ """Collecte et déduplique les devoirs en deux passes globales.
+
+ Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
+ de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
+ le jour cible) des cours du jour ``target_date``. La déduplication
+ se fait par texte normalisé (premier venu, premier servi) et le
+ résultat est trié par matière puis texte.
+
+ :param lessons: Liste de tous les cours (VEVENT) parsés.
+ :param target_date: Date cible pour laquelle collecter les devoirs.
+ :return: Liste unique de devoirs, triée par matière puis texte.
+ :rtype: list[Homework]
+ """
+ by_text: dict[str, Homework] = {}
+
+ for lesson in lessons:
+ for block in lesson.homework_blocks:
+ if block.kind == "due" and block.date == target_date:
+ key = normalize_homework_text(block.text)
+ if key not in by_text:
+ by_text[key] = Homework(
+ id=generate_homework_id(target_date, key),
+ subject=lesson.subject,
+ teachers=lesson.teachers,
+ assigned_on=lesson.start.date(),
+ due_on=block.date,
+ text=block.text,
+ html=block.html,
+ )
+
+ for lesson in lessons:
+ if lesson.start.date() != target_date:
+ continue
+ for block in lesson.homework_blocks:
+ if block.kind == "assigned":
+ key = normalize_homework_text(block.text)
+ if key not in by_text:
+ by_text[key] = Homework(
+ id=generate_homework_id(target_date, key),
+ subject=lesson.subject,
+ teachers=lesson.teachers,
+ assigned_on=block.date,
+ due_on=target_date,
+ text=block.text,
+ html=block.html,
+ )
+
+ return sorted(by_text.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
+
+
+def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
+ """Parse un flux iCal Pronote en événements typés.
+
+ Les VEVENT de vacances/congés (tout le jour) deviennent des
+ :class:`SchoolEvent` de type ``holiday``. Les VEVENT horodatés
+ deviennent des :class:`Lesson` dont le statut dérive de la propriété
+ ``STATUS`` (``CANCELLED`` → ``CANCELLED``) et de la catégorie
+ (``Cours - Cours annulé`` → ``CANCELLED``, ``Cours - Cours déplacé`` →
+ ``MOVED``). Les UID sont normalisés ;
+ un événement sans UID reçoit un UID déterministe généré à partir
+ de ses champs clés (début, fin, matière, enseignants, salles, groupe).
+
+ :param raw_ical: Contenu brut du flux iCal.
+ :return: Tuple ``(lessons, homeworks, school_events)`` où ``homeworks``
+ est **toujours vide** : la collecte/déduplication se fait plus tard
+ via :func:`collect_homeworks` une fois la date cible connue.
+ :rtype: tuple[list[Lesson], list[Homework], list[SchoolEvent]]
+ """
+ calendar = Calendar.from_ical(raw_ical)
+
+ lessons: list[Lesson] = []
+ homeworks: list[Homework] = []
+ school_events: list[SchoolEvent] = []
+
+ for component in calendar.walk():
+ if component.name != "VEVENT":
+ continue
+
+ dtstart = component.get("dtstart")
+ dtend = component.get("dtend")
+ if dtstart is None or dtend is None:
+ continue
+ start = dtstart.dt
+ end = dtend.dt
+
+ categories_obj = component.get("categories")
+ if categories_obj is None:
+ categories: list[str] = []
+ else:
+ categories = [str(category) for category in categories_obj.cats]
+
+ # Événements de type vacances/congés (tout le jour).
+ if any(cat in ("Congés", "Vacances") for cat in categories):
+ from_date = start.date() if isinstance(start, datetime) else start
+ to_date = end.date() if isinstance(end, datetime) else end
+ summary = component.get("summary")
+ label = str(summary) if summary is not None else ""
+ school_events.append(
+ SchoolEvent(
+ kind=SchoolEventKind.HOLIDAY,
+ label=label,
+ from_date=from_date,
+ to_date=to_date,
+ )
+ )
+ continue
+
+ # Les cours sont des événements horodatés ; les événements tout
+ # le jour non scolaires sont ignorés.
+ if not isinstance(start, datetime) or not isinstance(end, datetime):
+ continue
+
+ status_obj = component.get("status")
+ status_value = str(status_obj).strip().upper() if status_obj is not None else ""
+ if status_value == "CANCELLED" or "Cours - Cours annulé" in categories:
+ status = LessonStatus.CANCELLED
+ elif "Cours - Cours déplacé" in categories:
+ status = LessonStatus.MOVED
+ else:
+ status = LessonStatus.NORMAL
+
+ description = component.get("description")
+ description_str = str(description) if description is not None else ""
+ header, body = split_header_and_body(description_str)
+ lesson_data = parse_header(header)
+ content, due_blocks, assigned_blocks = parse_body(body)
+ homework_blocks = parse_homework_blocks(due_blocks, assigned_blocks)
+
+ uid_value = component.get("uid")
+ if uid_value is None or str(uid_value).strip() == "":
+ uid = generate_deterministic_uid(
+ start=start,
+ end=end,
+ subject=lesson_data["subject"],
+ teachers=lesson_data["teachers"],
+ rooms=lesson_data["rooms"],
+ group=lesson_data["group"],
+ )
+ else:
+ uid = normalize_pronote_uid(str(uid_value))
+
+ lessons.append(
+ Lesson(
+ id=uid,
+ start=start,
+ end=end,
+ subject=lesson_data["subject"],
+ teachers=tuple(lesson_data["teachers"]),
+ rooms=tuple(lesson_data["rooms"]),
+ group=lesson_data["group"],
+ status=status,
+ content=content,
+ homework_blocks=homework_blocks,
+ )
+ )
+
+ return lessons, homeworks, school_events
diff --git a/tests/fixtures/pronote-4e.ics b/tests/fixtures/pronote-4e.ics
new file mode 100644
index 0000000..80a48ff
--- /dev/null
+++ b/tests/fixtures/pronote-4e.ics
@@ -0,0 +1,57 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Index Education//Pronote//FR
+X-WR-CALNAME:Classe de 4e
+BEGIN:VEVENT
+UID:Edt_12345@index-education.net-20260905T120000Z-Index-Education
+DTSTAMP:20260905T120000Z
+DTSTART:20260905T080000Z
+DTEND:20260905T090000Z
+SUMMARY:Mathématiques
+CATEGORIES:Cours
+DESCRIPTION:
+ Matière : Mathématiques
+ Professeur : M. Dupont
+ Salle : 204
+ Groupe : Classe entière
+
+ Contenu pédagogique :
+
+ Résoudre des équations du second degré.
+ Pour le 10/09/2026 :
+
+ Exercices 1 à 5 page 42.
+ Donné le 05/09/2026 :
+
+ Exercices 1 à 5 page 42.
+
+END:VEVENT
+BEGIN:VEVENT
+UID:Edt_67890@index-education.net-20260905T120000Z-Index-Education
+DTSTAMP:20260905T120000Z
+DTSTART:20260905T090000Z
+DTEND:20260905T100000Z
+SUMMARY:Français
+CATEGORIES:Cours - Cours annulé
+STATUS:CANCELLED
+DESCRIPTION:
+ Matière : Français
+ Professeur : Mme Martin
+ Salle : 205
+ Groupe : Classe entière
+
+ Contenu pédagogique :
+
+ Étude d'un texte littéraire.
+
+END:VEVENT
+BEGIN:VEVENT
+UID:Edt_11111@index-education.net-20260905T120000Z-Index-Education
+DTSTAMP:20260905T120000Z
+DTSTART;VALUE=DATE:20261020
+DTEND;VALUE=DATE:20261021
+SUMMARY:Vacances de la Toussaint
+CATEGORIES:Congés
+DESCRIPTION:Période de vacances scolaires.
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/unit/test_fallback.py b/tests/unit/test_fallback.py
new file mode 100644
index 0000000..57c502b
--- /dev/null
+++ b/tests/unit/test_fallback.py
@@ -0,0 +1,987 @@
+"""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
+ """
+ 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
+ """
+ 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
+ """
+ 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
+ """
+ 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
+ """
+ # 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
+ """
+ # 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
+ """
+ # 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
+ """
+ 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="Devoir sur les fonctions
",
+ ),
+ ),
+ )
+ ]
+
+ 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="Devoir sur les fonctions
",
+ )
+ ]
+
+ 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
+ """
+ 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
+ """
+ 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
+ """
+ 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
+ """
+ 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
+ """
+ # 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_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="Devoir pour aujourd'hui
",
+ ),
+ Homework(
+ id="hw2",
+ subject="Physique",
+ teachers=(),
+ assigned_on=None,
+ due_on=other_date,
+ text="Devoir pour demain",
+ html="Devoir pour demain
",
+ ),
+ ]
+
+ 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
+ )
+
+
+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
+ """
+ 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
+ """
+ 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 si ICAL échoue et pronotepy aussi.
+
+ On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec.
+ On vérifie que l'erreur PipelineCriticalError est levée.
+
+ :param mock_fetcher: Fetcher de test.
+ :return: 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()
+
+ assert "les sources ical et pronotepy ont échoué" in str(exc_info.value)
+
+
+def test_fetch_agenda_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.
+
+ :param mock_fetcher: Fetcher de test.
+ :param caplog: Fixture pytest pour capturer les logs.
+ :return: 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,
+ 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(mock_fetcher: PronoteFetcher) -> None:
+ """Test _homework_sources en mode ICAL.
+
+ On vérifie que la source primaire est ical et que le repli est pronotepy si configuré,
+ ou None sinon.
+
+ :param mock_fetcher: Fetcher de test.
+ :return: None
+ """
+ mock_fetcher._settings.pronote.homework_source = "ical"
+
+ # With pronotepy configured
+ primary, fallback = mock_fetcher._homework_sources()
+ assert primary == "ical"
+ assert fallback == "pronotepy"
+
+ # 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(mock_fetcher: PronoteFetcher) -> None:
+ """Test _homework_sources en mode PRONOTEPY.
+
+ On vérifie que la source primaire est pronotepy et que le repli est ical si configuré,
+ ou None sinon.
+
+ :param mock_fetcher: Fetcher de test.
+ :return: None
+ """
+ mock_fetcher._settings.pronote.homework_source = "pronotepy"
+
+ # With ical configured
+ primary, fallback = mock_fetcher._homework_sources()
+ assert primary == "pronotepy"
+ assert fallback == "ical"
+
+ # 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
+ """
+ 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
+ """
+ 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_homework_fallback_both_fail_raises_pipeline_critical_error(
+ mock_fetcher: PronoteFetcher,
+) -> None:
+ """Test que fetch_homework lève PipelineCriticalError si ICAL échoue et pronotepy aussi.
+
+ On mock ICAL pour échouer, on garde pronotepy configuré mais on mock son échec.
+ On vérifie que l'erreur PipelineCriticalError est levée.
+
+ :param mock_fetcher: Fetcher de test.
+ :return: 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)
+
+ assert "les sources ical et pronotepy ont échoué" in str(exc_info.value)
+
+
+def test_fetch_homework_fallback_returns_empty_logs_warning(
+ mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide.
+
+ On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
+
+ :param mock_fetcher: Fetcher de test.
+ :param caplog: Fixture pytest pour capturer les logs.
+ :return: 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.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
+ """
+ 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
+ )
+
+
+# Ensure trailing newline
diff --git a/tests/unit/test_ical.py b/tests/unit/test_ical.py
new file mode 100644
index 0000000..0613ed5
--- /dev/null
+++ b/tests/unit/test_ical.py
@@ -0,0 +1,600 @@
+"""Tests unitaires pour le module iCal : téléchargement et parsing.
+
+Ce module teste :
+- La récupération du flux iCal (file://, HTTP)
+- Le parsing des événements en modèles Lesson, SchoolEvent
+- L'extraction et normalisation des devoirs
+- La collecte et déduplication des devoirs par date cible
+
+Les tests utilisent des mocks pour éviter tout appel réseau réel.
+"""
+
+from __future__ import annotations
+
+import tempfile
+import urllib.parse
+from datetime import date, datetime
+from pathlib import Path
+
+import pytest
+import responses
+
+from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus
+from pronote_sync.sources.pronote.ical import (
+ collect_homeworks,
+ fetch_ical,
+ generate_homework_id,
+ get_calendar_name,
+ normalize_homework_text,
+ parse_body,
+ parse_ical,
+)
+
+
+@pytest.fixture
+def valid_ical_content() -> str:
+ """Contenu iCal valide pour tests de parsing."""
+ return """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//FR
+X-WR-CALNAME:Test Calendar
+BEGIN:VEVENT
+UID:test-1@test.net
+DTSTAMP:20260905T120000Z
+DTSTART:20260905T080000Z
+DTEND:20260905T090000Z
+SUMMARY:Math
+CATEGORIES:Cours
+DESCRIPTION:Matière : Math\nProfesseur : M. Dupont\nSalle : 204\nContenu pédagogique :Résoudre des équations.
+END:VEVENT
+END:VCALENDAR
+"""
+
+
+@pytest.fixture
+def invalid_ical_content() -> str:
+ """Contenu iCal invalide (sans BEGIN:VCALENDAR)."""
+ return "INVALID:CONTENT\nThis is not a valid iCal file."
+
+
+def test_fetch_ical_file_protocol() -> None:
+ """fetch_ical("file://tests/fixtures/pronote-4e.ics") retourne un contenu commençant par BEGIN:VCALENDAR.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ url = f"file://{fixture_path}"
+
+ content = fetch_ical(url)
+ assert content.lstrip().startswith("BEGIN:VCALENDAR")
+
+
+def test_fetch_ical_file_uri_decoding() -> None:
+ """fetch_ical("file://path%20with%20spaces") décode correctement le chemin.
+
+ :return: None
+ """
+ # Créer un fichier temporaire avec un espace dans le nom
+ with tempfile.TemporaryDirectory() as tmpdir:
+ temp_path = Path(tmpdir) / "fichier avec espaces.ics"
+ temp_path.write_text(
+ "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR",
+ encoding="utf-8",
+ )
+
+ # URL encodée avec espace
+ encoded_name = urllib.parse.quote("fichier avec espaces.ics")
+ url = f"file://{tmpdir}/{encoded_name}"
+
+ # Cela devrait fonctionner car Path.read_text décode l'URL
+ content = fetch_ical(url)
+ assert content.lstrip().startswith("BEGIN:VCALENDAR")
+
+
+@responses.activate
+def test_fetch_ical_invalid_content() -> None:
+ """Si le contenu ne commence pas par BEGIN:VCALENDAR, une exception est levée.
+
+ :return: None
+ """
+ responses.add(
+ responses.GET,
+ "https://example.com/ical.ics",
+ body="INVALID:CONTENT",
+ status=200,
+ )
+
+ with pytest.raises(ValueError, match="Flux iCal invalide"):
+ fetch_ical("https://example.com/ical.ics")
+
+
+@responses.activate
+def test_fetch_ical_http() -> None:
+ """Mock de requests.get pour retourner un contenu iCal valide.
+
+ :return: None
+ """
+ valid_content = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR"
+
+ responses.add(
+ responses.GET,
+ "https://pronote.example.com/ical.ics",
+ body=valid_content,
+ status=200,
+ )
+
+ content = fetch_ical("https://pronote.example.com/ical.ics")
+ assert content == valid_content
+
+
+@responses.activate
+def test_fetch_ical_redacts_errors() -> None:
+ """Les messages d'erreur ne contiennent pas l'URL complète (doit être masquée).
+
+ :return: None
+ """
+ responses.add(
+ responses.GET,
+ "https://pronote.example.com/ical.ics",
+ body=Exception("Erreur réseau"),
+ status=500,
+ )
+
+ with pytest.raises(Exception) as exc_info:
+ fetch_ical("https://pronote.example.com/ical.ics?token=secret123")
+
+ error_msg = str(exc_info.value)
+ # Vérifie que le token secret n'est pas dans le message
+ assert "secret123" not in error_msg
+ # Vérifie que l'URL est masquée (utilise redact_url)
+ assert "https://pronote.example.com/ical.ics" in error_msg
+ # Le message doit contenir la partie masquée
+ assert "...ics" in error_msg or "pronote.example.com/ical" in error_msg
+
+
+def test_get_calendar_name() -> None:
+ """get_calendar_name(raw_ical) retourne le nom du calendrier.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ name = get_calendar_name(content)
+ assert name == "Classe de 4e"
+
+
+def test_get_calendar_name_with_params() -> None:
+ """Test avec X-WR-CALNAME;LANGUAGE=fr:TestName.
+
+ :return: None
+ """
+ raw_ical = """BEGIN:VCALENDAR
+X-WR-CALNAME;LANGUAGE=fr:TestName
+END:VCALENDAR
+"""
+ name = get_calendar_name(raw_ical)
+ assert name == "TestName"
+
+
+def test_get_calendar_name_none() -> None:
+ """Retourne None si X-WR-CALNAME est absent.
+
+ :return: None
+ """
+ raw_ical = """BEGIN:VCALENDAR
+VERSION:2.0
+END:VCALENDAR
+"""
+ name = get_calendar_name(raw_ical)
+ assert name is None
+
+
+def test_parse_ical_returns_lessons() -> None:
+ """parse_ical(fixture_content) retourne au moins 2 cours.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ lessons, homeworks, school_events = parse_ical(content)
+ assert len(lessons) >= 2
+
+
+def test_parse_ical_detects_cancelled_course() -> None:
+ """Un cours a status == LessonStatus.CANCELLED.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ lessons, _, _ = parse_ical(content)
+ cancelled_lessons = [lesson for lesson in lessons if lesson.status == LessonStatus.CANCELLED]
+ assert len(cancelled_lessons) >= 1
+
+
+def test_parse_ical_returns_school_events() -> None:
+ """Au moins 1 événement scolaire est retourné.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ _, _, school_events = parse_ical(content)
+ assert len(school_events) >= 1
+
+
+def test_parse_ical_homeworks_empty() -> None:
+ """La liste des devoirs est toujours vide depuis parse_ical.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ _, homeworks, _ = parse_ical(content)
+ assert homeworks == []
+
+
+def test_parse_ical_lesson_fields() -> None:
+ """Vérifie qu'un cours a les bons champs (matière, profs, salles).
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ lessons, _, _ = parse_ical(content)
+ assert len(lessons) > 0
+
+ # Vérifions le premier cours (Mathématiques)
+ lesson = lessons[0]
+ assert lesson.subject == "Mathématiques"
+ assert lesson.teachers == ("M. Dupont",)
+ assert lesson.rooms == ("204",)
+ assert lesson.status == LessonStatus.NORMAL
+
+
+def test_collect_homeworks_dedup() -> None:
+ """Étant donné des cours avec des blocs de devoirs, collect_homeworks retourne des devoirs dédupliqués.
+
+ :return: None
+ """
+ # Créer des cours avec des blocs de devoirs en double
+ lesson1 = Lesson(
+ id="lesson1",
+ start=datetime(2026, 9, 10, 8, 0),
+ end=datetime(2026, 9, 10, 9, 0),
+ subject="Math",
+ teachers=("M. Dupont",),
+ rooms=("204",),
+ group=None,
+ status=LessonStatus.NORMAL,
+ content=None,
+ homework_blocks=(
+ HomeworkBlock(
+ kind="due",
+ date=date(2026, 9, 10),
+ text="Exercice 1 à 5 page 42",
+ html="Exercice 1 à 5 page 42
",
+ ),
+ ),
+ )
+
+ lesson2 = Lesson(
+ id="lesson2",
+ start=datetime(2026, 9, 10, 10, 0),
+ end=datetime(2026, 9, 10, 11, 0),
+ subject="Physique",
+ teachers=("M. Martin",),
+ rooms=("205",),
+ group=None,
+ status=LessonStatus.NORMAL,
+ content=None,
+ homework_blocks=(
+ HomeworkBlock(
+ kind="due",
+ date=date(2026, 9, 10),
+ text="Exercice 1 à 5 page 42",
+ html="Exercice 1 à 5 page 42
",
+ ),
+ ),
+ )
+
+ homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
+ assert len(homeworks) == 1 # Un seul devoir dédupliqué
+
+
+def test_collect_homeworks_id_stability() -> None:
+ """Deux devoirs avec le même texte et date d'échéance produisent le même ID.
+
+ :return: None
+ """
+ lesson1 = Lesson(
+ id="lesson1",
+ start=datetime(2026, 9, 10, 8, 0),
+ end=datetime(2026, 9, 10, 9, 0),
+ subject="Math",
+ teachers=("M. Dupont",),
+ rooms=("204",),
+ group=None,
+ status=LessonStatus.NORMAL,
+ content=None,
+ homework_blocks=(
+ HomeworkBlock(
+ kind="due",
+ date=date(2026, 9, 10),
+ text="Devoir commun",
+ html="Devoir commun
",
+ ),
+ ),
+ )
+
+ lesson2 = Lesson(
+ id="lesson2",
+ start=datetime(2026, 9, 10, 10, 0),
+ end=datetime(2026, 9, 10, 11, 0),
+ subject="Physique",
+ teachers=("M. Martin",),
+ rooms=("205",),
+ group=None,
+ status=LessonStatus.NORMAL,
+ content=None,
+ homework_blocks=(
+ HomeworkBlock(
+ kind="due",
+ date=date(2026, 9, 10),
+ text="Devoir commun",
+ html="Devoir commun
",
+ ),
+ ),
+ )
+
+ homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
+ assert len(homeworks) == 1
+ assert homeworks[0].id == generate_homework_id(date(2026, 9, 10), "devoir commun")
+
+
+def test_collect_homeworks_sorted() -> None:
+ """Les résultats sont triés par (subject.lower(), text.lower()).
+
+ :return: None
+ """
+ lesson1 = Lesson(
+ id="lesson1",
+ start=datetime(2026, 9, 10, 8, 0),
+ end=datetime(2026, 9, 10, 9, 0),
+ subject="Zoologie",
+ teachers=("M. A",),
+ rooms=("204",),
+ group=None,
+ status=LessonStatus.NORMAL,
+ content=None,
+ homework_blocks=(
+ HomeworkBlock(
+ kind="due", date=date(2026, 9, 10), text="Devoir B", html="Devoir B
"
+ ),
+ ),
+ )
+
+ lesson2 = Lesson(
+ id="lesson2",
+ start=datetime(2026, 9, 10, 10, 0),
+ end=datetime(2026, 9, 10, 11, 0),
+ subject="Mathématiques",
+ teachers=("M. B",),
+ rooms=("205",),
+ group=None,
+ status=LessonStatus.NORMAL,
+ content=None,
+ homework_blocks=(
+ HomeworkBlock(
+ kind="due", date=date(2026, 9, 10), text="Devoir A", html="Devoir A
"
+ ),
+ ),
+ )
+
+ homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
+ assert len(homeworks) == 2
+ assert homeworks[0].subject == "Mathématiques"
+ assert homeworks[0].text == "Devoir A"
+ assert homeworks[1].subject == "Zoologie"
+ assert homeworks[1].text == "Devoir B"
+
+
+def test_collect_homeworks_empty() -> None:
+ """Aucun bloc de devoir → liste vide.
+
+ :return: None
+ """
+ lesson = Lesson(
+ id="lesson1",
+ start=datetime(2026, 9, 10, 8, 0),
+ end=datetime(2026, 9, 10, 9, 0),
+ subject="Math",
+ teachers=("M. Dupont",),
+ rooms=("204",),
+ group=None,
+ status=LessonStatus.NORMAL,
+ content=None,
+ )
+
+ homeworks = collect_homeworks([lesson], target_date=date(2026, 9, 10))
+ assert homeworks == []
+
+
+def test_normalize_homework_text() -> None:
+ """Vérifie la normalisation des espaces, suppression HTML et minuscules.
+
+ :return: None
+ """
+ text = " Exercice 1 à 5
\n\n page 42 "
+ normalized = normalize_homework_text(text)
+ assert normalized == "exercice 1 à 5 page 42"
+
+
+def test_generate_homework_id_format() -> None:
+ """Retourne un ID de 12 caractères hexadécimaux.
+
+ :return: None
+ """
+ due_on = date(2026, 9, 10)
+ text = "devoir test"
+ homework_id = generate_homework_id(due_on, text)
+ assert len(homework_id) == 12
+ assert all(c in "0123456789abcdef" for c in homework_id)
+
+
+def test_generate_homework_id_deterministic() -> None:
+ """Mêmes entrées → même sortie.
+
+ :return: None
+ """
+ due_on = date(2026, 9, 10)
+ text = "devoir commun"
+ id1 = generate_homework_id(due_on, text)
+ id2 = generate_homework_id(due_on, text)
+ assert id1 == id2
+
+
+CANCELLED_STATUS_ICAL = """BEGIN:VCALENDAR
+VERSION:2.0
+X-WR-CALNAME:Test
+BEGIN:VEVENT
+UID:Test-123-20260906T120000Z-Index-Education
+DTSTART:20260907T080000Z
+DTEND:20260907T090000Z
+SUMMARY:Test Course
+STATUS:CANCELLED
+DESCRIPTION:
+END:VEVENT
+END:VCALENDAR"""
+
+
+NORMAL_STATUS_ICAL = """BEGIN:VCALENDAR
+VERSION:2.0
+X-WR-CALNAME:Test
+BEGIN:VEVENT
+UID:Test-123-20260906T120000Z-Index-Education
+DTSTART:20260907T080000Z
+DTEND:20260907T090000Z
+SUMMARY:Test Course
+STATUS:CONFIRMED
+DESCRIPTION:
+END:VEVENT
+END:VCALENDAR"""
+
+
+MOVED_BY_CATEGORY_ICAL = """BEGIN:VCALENDAR
+VERSION:2.0
+X-WR-CALNAME:Test
+BEGIN:VEVENT
+UID:Test-123-20260906T120000Z-Index-Education
+DTSTART:20260907T080000Z
+DTEND:20260907T090000Z
+SUMMARY:Test Course
+CATEGORIES:Cours - Cours déplacé
+DESCRIPTION:
+END:VEVENT
+END:VCALENDAR"""
+
+
+MULTIPLE_BLOCKS_SAME_DATE_ICAL = """BEGIN:VCALENDAR
+VERSION:2.0
+X-WR-CALNAME:Test
+BEGIN:VEVENT
+UID:Test-123-20260906T120000Z-Index-Education
+DTSTART:20260907T080000Z
+DTEND:20260907T090000Z
+SUMMARY:Test Course
+CATEGORIES:Cours
+DESCRIPTION:
+ Matière : Math
+ Professeur : M. Dupont
+ Salle : 204
+
+ Pour le 10/09/2026 :
+ Exercice 1 à 5 page 42.
+ Pour le 10/09/2026 :
+ Exercice 6 à 10 page 43.
+
+END:VEVENT
+END:VCALENDAR"""
+
+
+def test_parse_ical_status_cancelled_only() -> None:
+ """Un cours avec STATUS:CANCELLED mais sans CATEGORIES contenant 'Cours annulé' a status == LessonStatus.CANCELLED.
+
+ :return: None
+ """
+ lessons, _, _ = parse_ical(CANCELLED_STATUS_ICAL)
+ assert len(lessons) == 1
+ assert lessons[0].status == LessonStatus.CANCELLED
+
+
+def test_parse_ical_status_normal_without_cancel() -> None:
+ """Un cours avec STATUS:CONFIRMED (ou sans STATUS) a status == LessonStatus.NORMAL.
+
+ :return: None
+ """
+ lessons, _, _ = parse_ical(NORMAL_STATUS_ICAL)
+ assert len(lessons) == 1
+ assert lessons[0].status == LessonStatus.NORMAL
+
+
+def test_parse_ical_moved_by_category_only() -> None:
+ """Un cours avec CATEGORIES:Cours - Cours déplacé et sans STATUS a status == LessonStatus.MOVED.
+
+ :return: None
+ """
+ lessons, _, _ = parse_ical(MOVED_BY_CATEGORY_ICAL)
+ assert len(lessons) == 1
+ assert lessons[0].status == LessonStatus.MOVED
+
+
+def test_parse_body_multiple_blocks_same_date() -> None:
+ """Un DESCRIPTION avec deux sections 'Pour le' à la même date conserve les deux blocs.
+
+ :return: None
+ """
+ body_html = (
+ "\n"
+ " Pour le 10/09/2026:\n"
+ " Exercice 1 à 5 page 42.\n"
+ " Pour le 10/09/2026:\n"
+ " Exercice 6 à 10 page 43.\n"
+ "
"
+ )
+ content, due_blocks, assigned_blocks = parse_body(body_html)
+ assert len(due_blocks) == 2
+ assert due_blocks[0][0] == date(2026, 9, 10)
+ assert due_blocks[0][1] == "Exercice 1 à 5 page 42."
+ assert due_blocks[1][0] == date(2026, 9, 10)
+ assert due_blocks[1][1] == "Exercice 6 à 10 page 43."
+
+
+def test_collect_homeworks_from_fixture() -> None:
+ """Parse le fixture pronote-4e.ics, appelle collect_homeworks pour le 10/09/2026 et vérifie qu'au moins un devoir est retourné.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ lessons, _, _ = parse_ical(content)
+ homeworks = collect_homeworks(lessons, date(2026, 9, 10))
+
+ assert len(homeworks) >= 1
+ # Vérifie qu'au moins un devoir a le bon sujet et texte
+ assert any(hw.subject == "Mathématiques" for hw in homeworks)
+ assert any("Exercices 1 à 5 page 42" in hw.text for hw in homeworks)
diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py
new file mode 100644
index 0000000..248c6b0
--- /dev/null
+++ b/tests/unit/test_pronote_client.py
@@ -0,0 +1,439 @@
+"""Tests unitaires pour le client Pronote via pronotepy.
+
+Ce module vérifie le comportement du client ``PronoteClient`` et de son
+protocole ``PronoteClientProtocol``. Tous les tests sont unitaires et
+utilisent des mocks pour éviter tout accès réseau réel à Pronote.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+
+import pronotepy
+import pytest
+import pytest_mock
+from pydantic import SecretStr
+
+from pronote_sync.config.settings import PronoteSettings
+from pronote_sync.models.agenda import Lesson, LessonStatus
+from pronote_sync.models.homework import Homework
+from pronote_sync.models.message import Message, MessageType
+from pronote_sync.sources.pronote.client import PronoteClient, PronoteClientProtocol
+
+# --- Protocol tests ---
+
+
+def test_protocol_methods(mocker: pytest_mock.MockerFixture) -> None:
+ """Vérifie que le protocole PronoteClientProtocol expose les méthodes attendues.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :return: None
+ """
+ assert hasattr(PronoteClientProtocol, "get_messages")
+ assert hasattr(PronoteClientProtocol, "get_informations")
+ assert hasattr(PronoteClientProtocol, "get_lessons")
+ assert hasattr(PronoteClientProtocol, "get_homeworks")
+
+
+# --- Client with mocked pronotepy ---
+
+
+@pytest.fixture
+def pronote_settings() -> PronoteSettings:
+ """Fournit des paramètres Pronote valides pour les tests.
+
+ :return: Instance de PronoteSettings avec des valeurs par défaut.
+ :rtype: PronoteSettings
+ """
+ return PronoteSettings(
+ pronote_url="https://pronote.example.com",
+ username="testuser",
+ password=SecretStr("testpass"),
+ ent="bordeaux",
+ account_type="parent",
+ )
+
+
+@pytest.fixture
+def empty_pronote_settings() -> PronoteSettings:
+ """Fournit des paramètres Pronote vides pour les tests.
+
+ :return: Instance de PronoteSettings avec tous les champs à None.
+ :rtype: PronoteSettings
+ """
+ return PronoteSettings(username=None, password=None, ent=None)
+
+
+def test_get_messages_success(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_messages retourne une liste de Message en cas de succès.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ # Mock du client pronotepy
+ mock_client = mocker.MagicMock()
+ mock_discussion = mocker.MagicMock()
+ mock_discussion.subject = "Test Subject"
+ mock_message = mocker.MagicMock()
+ mock_message.id = "msg-123"
+ mock_message.content = "Test message content"
+ mock_message.author = "Teacher Test"
+ mock_message.created = datetime(2024, 9, 1, 10, 0, 0)
+ mock_message.seen = True
+ mock_discussion.messages = [mock_message]
+ mock_client.discussions.return_value = [mock_discussion]
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ messages = client.get_messages()
+
+ assert isinstance(messages, list)
+ assert len(messages) == 1
+ message = messages[0]
+ assert isinstance(message, Message)
+ assert message.id == "msg-123"
+ assert message.type == MessageType.DISCUSSION
+ assert message.title == "Test Subject"
+ assert message.content == "Test message content"
+ assert message.author == "Teacher Test"
+ assert message.date == datetime(2024, 9, 1, 10, 0, 0)
+ assert message.read is True
+
+
+def test_get_messages_empty_on_error(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_messages retourne une liste vide en cas d'erreur API.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+ mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error")
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ messages = client.get_messages()
+
+ assert messages == []
+
+
+def test_get_informations_success(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_informations retourne une liste de Message en cas de succès.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+ mock_info = mocker.MagicMock()
+ mock_info.id = "info-456"
+ mock_info.title = "Important Info"
+ mock_info.content.return_value = "Important content"
+ mock_info.author = "Admin"
+ mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
+ mock_info.read = False
+ mock_info.survey = True
+ mock_client.information_and_surveys.return_value = [mock_info]
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ messages = client.get_informations()
+
+ assert isinstance(messages, list)
+ assert len(messages) == 1
+ message = messages[0]
+ assert isinstance(message, Message)
+ assert message.id == "info-456"
+ assert message.type == MessageType.SURVEY
+ assert message.title == "Important Info"
+ assert message.content == "Important content"
+ assert message.author == "Admin"
+ assert message.date == datetime(2024, 9, 2, 14, 30, 0)
+ assert message.read is False
+
+
+def test_get_informations_empty_on_error(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_informations retourne une liste vide en cas d'erreur API.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+ mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ messages = client.get_informations()
+
+ assert messages == []
+
+
+def test_get_lessons_success(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_lessons retourne une liste de Lesson en cas de succès.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+
+ # Mock des cours
+ mock_lesson = mocker.MagicMock()
+ mock_lesson.id = "lesson-789"
+ mock_lesson.start = datetime(2024, 9, 1, 8, 0, 0)
+ mock_lesson.end = datetime(2024, 9, 1, 9, 30, 0)
+ mock_lesson.subject = mocker.MagicMock()
+ mock_lesson.subject.name = "Maths"
+ mock_lesson.teacher_names = ["Prof A", "Prof B"]
+ mock_lesson.classrooms = ["Salle 101", "Salle 102"]
+ mock_lesson.group_name = "Classe 1"
+ mock_lesson.canceled = False
+ mock_content = mocker.MagicMock()
+ mock_content.description = "Lesson content"
+ mock_lesson.content = mock_content
+
+ mock_client.lessons.return_value = [mock_lesson]
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
+
+ assert isinstance(lessons, list)
+ assert len(lessons) == 1
+ lesson = lessons[0]
+ assert isinstance(lesson, Lesson)
+ assert lesson.id == "lesson-789"
+ assert lesson.start == datetime(2024, 9, 1, 8, 0, 0)
+ assert lesson.end == datetime(2024, 9, 1, 9, 30, 0)
+ assert lesson.subject == "Maths"
+ assert lesson.teachers == ("Prof A", "Prof B")
+ assert lesson.rooms == ("Salle 101", "Salle 102")
+ assert lesson.group == "Classe 1"
+ assert lesson.status == LessonStatus.NORMAL
+ assert lesson.content == "Lesson content"
+
+
+def test_get_homeworks_success(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_homeworks retourne une liste de Homework en cas de succès.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+
+ # Mock des devoirs
+ mock_hw = mocker.MagicMock()
+ mock_hw.id = "hw-101"
+ mock_hw.subject = mocker.MagicMock()
+ mock_hw.subject.name = "Maths"
+ mock_hw.date = date(2024, 9, 15)
+ mock_hw.description = "Do your homework"
+
+ mock_client.homework.return_value = [mock_hw]
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ homeworks = client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
+
+ assert isinstance(homeworks, list)
+ assert len(homeworks) == 1
+ homework = homeworks[0]
+ assert isinstance(homework, Homework)
+ assert homework.id == "hw-101"
+ assert homework.subject == "Maths"
+ assert homework.teachers == ()
+ assert homework.assigned_on is None
+ assert homework.due_on == date(2024, 9, 15)
+ assert homework.text == "Do your homework"
+ assert homework.html == "Do your homework"
+
+
+def test_get_lessons_propagates_error(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_lessons propage les exceptions API.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+ mock_client.lessons.side_effect = pronotepy.PronoteAPIError("API error")
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+
+ with pytest.raises(pronotepy.PronoteAPIError):
+ client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
+
+
+def test_get_homeworks_propagates_error(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_homeworks propage les exceptions API.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+ mock_client.homework.side_effect = pronotepy.PronoteAPIError("API error")
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+
+ with pytest.raises(pronotepy.PronoteAPIError):
+ client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
+
+
+def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) -> None:
+ """Vérifie que les appels échouent avec ValueError si les identifiants sont manquants.
+
+ :param empty_pronote_settings: Paramètres Pronote avec tous les champs à None.
+ :return: None
+ """
+ client = PronoteClient(empty_pronote_settings)
+
+ with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"):
+ client._connect()
+
+
+def test_connect_with_ent_resolution(mocker: pytest_mock.MockerFixture) -> None:
+ """Vérifie que _resolve_ent retourne le callable attendu pour un ENT connu.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :return: None
+ """
+ from pronote_sync.sources.pronote.client import _resolve_ent
+
+ resolver = _resolve_ent("bordeaux")
+ assert resolver is not None
+
+
+def test_connect_with_unknown_ent_raises(mocker: pytest_mock.MockerFixture) -> None:
+ """Vérifie que _resolve_ent lève ValueError pour un ENT inconnu.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :return: None
+ """
+ from pronote_sync.sources.pronote.client import _resolve_ent
+
+ with pytest.raises(ValueError) as exc_info:
+ _resolve_ent("inconnu")
+ assert "ENT inconnu : 'inconnu'" in str(exc_info.value)
+ assert "ENT supportés :" in str(exc_info.value)
+
+
+def test_connect_parent_account_type(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que account_type='parent' utilise pronotepy.ParentClient.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ from unittest.mock import Mock
+
+ from pronote_sync.sources.pronote.client import PronoteClient
+
+ mock_client = mocker.MagicMock()
+ mock_client_class = Mock(return_value=mock_client)
+ mocker.patch("pronotepy.ParentClient", new=mock_client_class)
+ mocker.patch("pronotepy.Client")
+
+ client = PronoteClient(pronote_settings)
+ _ = client._connect()
+
+ # Verify ParentClient was used
+ assert mock_client_class.call_count == 1
+ pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
+
+
+def test_connect_student_account_type(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que account_type='student' utilise pronotepy.Client.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ from unittest.mock import Mock
+
+ from pronote_sync.sources.pronote.client import PronoteClient
+
+ pronote_settings_student = PronoteSettings(
+ pronote_url="https://pronote.example.com",
+ username="testuser",
+ password=SecretStr("testpass"),
+ ent="bordeaux",
+ account_type="student",
+ )
+ mock_client = mocker.MagicMock()
+ mock_client_class = Mock(return_value=mock_client)
+ mocker.patch("pronotepy.Client", new=mock_client_class)
+ mocker.patch("pronotepy.ParentClient")
+
+ client = PronoteClient(pronote_settings_student)
+ _ = client._connect()
+
+ # Verify Client was used
+ assert mock_client_class.call_count == 1
+ pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
+
+
+def test_get_messages_degraded_on_error(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_messages retourne une liste vide en cas d'erreur réseau.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+ mock_client.discussions.side_effect = ConnectionError("Network error")
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ messages = client.get_messages()
+
+ assert messages == []
+
+
+def test_get_informations_degraded_on_error(
+ mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
+) -> None:
+ """Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
+
+ :param mocker: Fixture pytest-mock pour le mocking.
+ :param pronote_settings: Paramètres Pronote valides.
+ :return: None
+ """
+ mock_client = mocker.MagicMock()
+ mock_client.information_and_surveys.side_effect = TimeoutError("Timeout")
+ mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
+
+ client = PronoteClient(pronote_settings)
+ messages = client.get_informations()
+
+ assert messages == []
+
+
+# Ensure trailing newline