"""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( 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="url, username et password sont requis pour pronotepy"): 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_without_ent_but_with_required_credentials( mocker: pytest_mock.MockerFixture, ) -> None: """Vérifie que _connect() fonctionne sans ent mais avec les autres identifiants requis. Ce test valide que PRONOTE_ENT est optionnel pour une connexion directe Pronote. :param mocker: Fixture pytest-mock pour le mocking. :return: None """ from unittest.mock import Mock from pronote_sync.config.settings import PronoteSettings from pronote_sync.sources.pronote.client import PronoteClient # Settings sans ent mais avec les autres champs requis settings = PronoteSettings( url="https://pronote.example.com", username="testuser", password=SecretStr("testpass"), ent=None, # Explicitement None account_type="parent", ) 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(settings) connected_client = client._connect() # Should not raise ValueError about missing ent assert connected_client is mock_client # Verify ParentClient was called with ent=None mock_client_class.assert_called_once_with( pronote_url="https://pronote.example.com", username="testuser", password="testpass", # pragma: allowlist secret ent=None, # ent should be None, not resolved ) def test_connect_missing_required_credentials_still_raises( mocker: pytest_mock.MockerFixture, ) -> None: """Vérifie que _connect() lève ValueError si url, username ou password manquent. Ce test valide que l'erreur ne mentionne plus ent comme requis. :param mocker: Fixture pytest-mock pour le mocking. :return: None """ from pronote_sync.config.settings import PronoteSettings from pronote_sync.sources.pronote.client import PronoteClient # Settings avec ent mais sans url settings = PronoteSettings( url=None, username="testuser", password=SecretStr("testpass"), ent=None, account_type="parent", ) client = PronoteClient(settings) with pytest.raises(ValueError) as exc_info: client._connect() # Error should NOT mention ent as required assert "url, username et password sont requis" in str(exc_info.value) assert "ent" not in str(exc_info.value) def test_connect_missing_username_raises(mocker: pytest_mock.MockerFixture) -> None: """Vérifie que _connect() lève ValueError si username manque. :param mocker: Fixture pytest-mock pour le mocking. :return: None """ from pronote_sync.config.settings import PronoteSettings from pronote_sync.sources.pronote.client import PronoteClient settings = PronoteSettings( url="https://pronote.example.com", username=None, password=SecretStr("testpass"), ent=None, account_type="parent", ) client = PronoteClient(settings) with pytest.raises(ValueError) as exc_info: client._connect() assert "url, username et password sont requis" in str(exc_info.value) def test_connect_missing_password_raises(mocker: pytest_mock.MockerFixture) -> None: """Vérifie que _connect() lève ValueError si password manque. :param mocker: Fixture pytest-mock pour le mocking. :return: None """ from pronote_sync.config.settings import PronoteSettings from pronote_sync.sources.pronote.client import PronoteClient settings = PronoteSettings( url="https://pronote.example.com", username="testuser", password=None, ent=None, account_type="parent", ) client = PronoteClient(settings) with pytest.raises(ValueError) as exc_info: client._connect() assert "url, username et password sont requis" in str(exc_info.value) 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( 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_connect_with_ent_resolution_still_works( mocker: pytest_mock.MockerFixture, ) -> None: """Vérifie que _resolve_ent est appelé et fonctionne quand ent est fourni. Ce test valide que lorsque ent est fourni, il est toujours résolu via _resolve_ent. :param mocker: Fixture pytest-mock pour le mocking. :return: None """ from unittest.mock import Mock from pronote_sync.config.settings import PronoteSettings from pronote_sync.sources.pronote.client import PronoteClient settings = PronoteSettings( url="https://pronote.example.com", username="testuser", password=SecretStr("testpass"), ent="bordeaux", # ent est fourni account_type="parent", ) mock_client = mocker.MagicMock() mock_client_class = Mock(return_value=mock_client) mocker.patch("pronotepy.ParentClient", new=mock_client_class) # Mock _resolve_ent to return a mock resolver mock_resolver = Mock() mocker.patch( "pronote_sync.sources.pronote.client._resolve_ent", return_value=mock_resolver, ) client = PronoteClient(settings) _ = client._connect() # _resolve_ent should have been called from pronote_sync.sources.pronote.client import _resolve_ent as resolve_ent_func resolve_ent_func.assert_called_once_with("bordeaux") # type: ignore[attr-defined] # ParentClient should have been called with the resolved ent mock_client_class.assert_called_once_with( pronote_url="https://pronote.example.com", username="testuser", password="testpass", # pragma: allowlist secret ent=mock_resolver, ) 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