"""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