diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb1ad1c..eaebacb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: name: mypy entry: mypy language: python - additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0"] + additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0"] types: [python] pass_filenames: true diff --git a/tests/unit/test_fallback.py b/tests/unit/test_fallback.py new file mode 100644 index 0000000..e81613c --- /dev/null +++ b/tests/unit/test_fallback.py @@ -0,0 +1,532 @@ +"""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_agenda_fallback( + self, start: date, end: date + ) -> tuple[list[Lesson], 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( + 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_agenda_fallback`` 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_agenda_fallback.return_value = (lessons, []) + mock_fetcher._pronote_client = client + + result_lessons, result_events = mock_fetcher.fetch_agenda() + + assert result_lessons == lessons + assert result_events == [] + client.get_agenda_fallback.assert_called_once() + + +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_agenda_fallback.return_value = (lessons, []) + mock_fetcher._pronote_client = client + + result_lessons, _ = mock_fetcher.fetch_agenda() + + assert result_lessons == lessons + client.get_agenda_fallback.assert_called_once() + + +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 + """ + 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 "iCal et pronotepy" in str(exc_info.value) + + +def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None: + """Test le mode ical : échec → PipelineCriticalError masquée. + + On mock iCal pour échouer. On vérifie que l'erreur brute est masquée dans la levée. + + :param mock_fetcher: Fetcher de test. + :return: 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( + "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. 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 + mock_fetcher._settings.pronote.agenda_source = "pronotepy" + + client = MagicMock() + 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_agenda_fallback.return_value = ([], homeworks) + mock_fetcher._pronote_client = client + m_collect.return_value = homeworks + + result = mock_fetcher.fetch_homework(target_date) + + assert result == homeworks + client.get_agenda_fallback.assert_called_once() + + +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) + + 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 "iCal et pronotepy" 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 = [] + client.get_agenda_fallback.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 = [] + client.get_agenda_fallback.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 + """ + 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 + + +# Ensure trailing newline diff --git a/tests/unit/test_ical.py b/tests/unit/test_ical.py new file mode 100644 index 0000000..0f94ab3 --- /dev/null +++ b/tests/unit/test_ical.py @@ -0,0 +1,465 @@ +"""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_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: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 diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py new file mode 100644 index 0000000..7a134e4 --- /dev/null +++ b/tests/unit/test_pronote_client.py @@ -0,0 +1,344 @@ +"""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_agenda_fallback") + + +# --- 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( + username="testuser", + password=SecretStr("testpass"), + ent="testent", + ) + + +@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("pronotepy.Client", 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("pronotepy.Client", 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("pronotepy.Client", 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("pronotepy.Client", return_value=mock_client) + + client = PronoteClient(pronote_settings) + messages = client.get_informations() + + assert messages == [] + + +def test_get_agenda_fallback_success( + mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings +) -> None: + """Vérifie que get_agenda_fallback retourne un tuple de listes 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 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.lessons.return_value = [mock_lesson] + mock_client.homework.return_value = [mock_hw] + mocker.patch("pronotepy.Client", return_value=mock_client) + + client = PronoteClient(pronote_settings) + lessons, homeworks = client.get_agenda_fallback(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" + + 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_agenda_fallback_empty_on_error( + mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings +) -> None: + """Vérifie que get_agenda_fallback retourne des listes vides 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.lessons.side_effect = pronotepy.PronoteAPIError("API error") + mocker.patch("pronotepy.Client", return_value=mock_client) + + client = PronoteClient(pronote_settings) + lessons, homeworks = client.get_agenda_fallback(date(2024, 9, 1), date(2024, 9, 30)) + + assert lessons == [] + assert homeworks == [] + + +def test_missing_credentials_returns_empty(empty_pronote_settings: PronoteSettings) -> None: + """Vérifie que les méthodes retournent une liste vide si les identifiants sont manquants. + + :param empty_pronote_settings: Paramètres Pronote avec tous les champs à None. + :return: None + """ + client = PronoteClient(empty_pronote_settings) + + messages = client.get_messages() + assert messages == [] + + informations = client.get_informations() + assert informations == [] + + lessons, homeworks = client.get_agenda_fallback(date(2024, 9, 1), date(2024, 9, 30)) + assert lessons == [] + assert homeworks == [] + + +def test_password_used_in_connection( + mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings +) -> None: + """Vérifie que le mot de passe est bien utilisé pour la connexion. + + :param mocker: Fixture pytest-mock pour le mocking. + :param pronote_settings: Paramètres Pronote valides. + :return: None + """ + # Patch pronotepy.Client to return our mock + from pronote_sync.sources.pronote import client as client_module + + mock_client = mocker.MagicMock() + mock_discussion = mocker.MagicMock() + mock_message = mocker.MagicMock() + mock_message.id = "msg-123" + mock_message.content = "Test" + mock_message.author = "Teacher" + mock_message.created = datetime(2024, 9, 1, 10, 0, 0) + mock_message.seen = False + mock_discussion.messages = [mock_message] + mock_discussion.subject = "Test Subject" + mock_client.discussions.return_value = [mock_discussion] + + # Patch pronotepy.Client to return our mock + mocker.patch.object(client_module, "pronotepy") + client_module.pronotepy.Client = lambda u, p, e: mock_client # type: ignore[attr-defined] # noqa: ARG005 + + # Setup mock client + mock_client = mocker.MagicMock() + mock_discussion = mocker.MagicMock() + mock_message = mocker.MagicMock() + mock_message.id = "msg-123" + mock_message.content = "Test" + mock_message.author = "Teacher" + mock_message.created = datetime(2024, 9, 1, 10, 0, 0) + mock_message.seen = False + mock_discussion.messages = [mock_message] + mock_discussion.subject = "Test Subject" + mock_client.discussions.return_value = [mock_discussion] + + # Patch pronotepy.Client to return our mock + mocker.patch("pronote_sync.sources.pronote.client.pronotepy.Client", return_value=mock_client) + + client = PronoteClient(pronote_settings) + _ = client.get_messages() + + # Vérifie que le client a été créé avec le mot de passe + # Le mock de Client doit avoir été appelé avec username, password, ent + client_class_mock = client_module.pronotepy.Client # type: ignore[attr-defined] + client_class_mock.assert_called_once() + call_args = client_class_mock.call_args + assert call_args is not None + assert len(call_args.args) >= 3 + assert call_args.args[0] == "testuser" + assert call_args.args[1] == "testpass" + assert call_args.args[2] == "testent" + + +# Ensure trailing newline