diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 6cc487f..7a63a4f 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -155,16 +155,17 @@ class PronoteClient: """Crée et connecte le client ``pronotepy`` (connexion paresseuse). Le client est créé une seule fois puis réutilisé pour les appels - suivants. Le nom d'ENT est résolu via :func:`_resolve_ent` et le - type de compte (``student`` ou ``parent``) détermine la classe de - client utilisée. L'erreur de connexion est relancée sans - journalisation, la méthode publique appelante étant responsable - de la journaliser. + suivants. Le nom d'ENT, s'il est configuré, est résolu via + :func:`_resolve_ent` ; en l'absence d'ENT, ``ent=None`` est transmis + à ``pronotepy`` pour une connexion directe. Le type de compte + (``student`` ou ``parent``) détermine la classe de client utilisée. + L'erreur de connexion est relancée sans journalisation, la méthode + publique appelante étant responsable de la journaliser. :return: Le client ``pronotepy`` connecté. :rtype: pronotepy.Client - :raises ValueError: Si ``pronote_url``, ``username``, ``password`` - ou ``ent`` est manquant, ou si l'ENT est inconnu. + :raises ValueError: Si ``pronote_url``, ``username`` ou ``password`` + est manquant, ou si l'ENT fourni est inconnu. :raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue. """ if self._client is None: @@ -172,11 +173,9 @@ class PronoteClient: username = self._settings.username password = self._settings.password ent = self._settings.ent - if pronote_url is None or username is None or password is None or ent is None: - raise ValueError( - "pronote_url, username, password et ent sont requis pour pronotepy" - ) - resolver = _resolve_ent(ent) + if pronote_url is None or username is None or password is None: + raise ValueError("pronote_url, username et password sont requis pour pronotepy") + resolver = _resolve_ent(ent) if ent is not None else None client_class: type[pronotepy.Client] = ( pronotepy.ParentClient if self._settings.account_type == "parent" diff --git a/pronote_sync/sources/pronote/fallback.py b/pronote_sync/sources/pronote/fallback.py index 9974c5d..3fbdcf3 100644 --- a/pronote_sync/sources/pronote/fallback.py +++ b/pronote_sync/sources/pronote/fallback.py @@ -149,8 +149,8 @@ class PronoteFetcher: def _is_pronotepy_configured(self) -> bool: """Vérifie que la source pronotepy est entièrement configurée. - :return: ``True`` si ``pronote_url``, ``username``, ``password`` - et ``ent`` sont tous définis, ``False`` sinon. + :return: ``True`` si ``pronote_url``, ``username`` et ``password`` + sont tous définis, ``False`` sinon. :rtype: bool """ pronote = self._settings.pronote @@ -158,7 +158,6 @@ class PronoteFetcher: pronote.pronote_url is not None and pronote.username is not None and pronote.password is not None - and pronote.ent is not None ) def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]: diff --git a/tests/unit/test_fallback.py b/tests/unit/test_fallback.py index 209fde2..a71afd3 100644 --- a/tests/unit/test_fallback.py +++ b/tests/unit/test_fallback.py @@ -1088,6 +1088,102 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value) +def test_fetch_agenda_auto_ical_configured_fails_fallback_to_pronotepy_without_ent( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode auto : iCal configuré mais échoue, repli sur pronotepy sans ent. + + On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé + et que le résultat provient de pronotepy, pas une erreur. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + start_dt = datetime(2025, 9, 1, 8, 0) + end_dt = datetime(2025, 9, 1, 9, 30) + lessons = [ + Lesson( + id="l1", + start=start_dt, + end=end_dt, + subject="Maths", + teachers=("Dupont",), + rooms=("S1",), + group="2ndeA", + status=LessonStatus.NORMAL, + content=None, + ) + ] + + # Override settings to use auto mode with ical_url configured but ent=None + mock_fetcher._settings.pronote.agenda_source = "auto" + mock_fetcher._settings.pronote.ent = None # Explicitly None + + with ( + patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, + patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, + ): + m_fetch_ical.side_effect = OSError("iCal unreachable") + m_parse_ical.side_effect = OSError("iCal parse error") + client = MagicMock() + client.get_lessons.return_value = lessons + mock_fetcher._pronote_client = client + + result_lessons, result_events = mock_fetcher.fetch_agenda() + + assert result_lessons == lessons + assert result_events == [] + client.get_lessons.assert_called_once() + + +def test_fetch_homework_auto_ical_configured_fails_fallback_to_pronotepy_without_ent( + mock_fetcher: PronoteFetcher, +) -> None: + """Test le mode auto des devoirs : iCal configuré mais échoue, repli sur pronotepy sans ent. + + On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé + et que le résultat provient de pronotepy, pas une erreur. + + :param mock_fetcher: Fetcher de test. + :return: None + :rtype: None + """ + target_date = date(2025, 9, 10) + homeworks = [ + Homework( + id="hw1", + subject="Physique", + teachers=(), + assigned_on=None, + due_on=target_date, + text="TP à préparer", + html="TP à préparer", + ) + ] + + # Override settings to use auto mode with ical_url configured but ent=None + mock_fetcher._settings.pronote.homework_source = "auto" + mock_fetcher._settings.pronote.ent = None # Explicitly None + + with ( + patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, + patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical, + patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect, + ): + m_fetch_ical.side_effect = OSError("iCal unreachable") + m_parse_ical.side_effect = OSError("iCal parse error") + client = MagicMock() + client.get_homeworks.return_value = homeworks + mock_fetcher._pronote_client = client + m_collect.return_value = homeworks + + result = mock_fetcher.fetch_homework(target_date) + + assert result == homeworks + client.get_homeworks.assert_called_once() + + def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error( mock_fetcher: PronoteFetcher, ) -> None: @@ -1127,7 +1223,7 @@ def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error( def test_fetch_homework_auto_fallback_returns_empty_logs_warning( mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture ) -> None: - """Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide. + """Test que fetch_homework retourne [] et journalise un avertissement si le repli est vide. On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log. Ce test utilise le mode AUTO pour tester le comportement de repli. @@ -1186,4 +1282,99 @@ def test_fetch_informations_logs_and_re_raises_secret( ) +def test_is_pronotepy_configured_without_ent_returns_true() -> None: + """Test _is_pronotepy_configured() retourne True quand ent est None. + + Les autres champs de la configuration pronotepy sont présents, donc la + fonction renvoie True. + + Ce test valide que PRONOTE_ENT est optionnel pour pronotepy. + + :return: None + """ + from pronote_sync.config.settings import PronoteSettings, Settings + from pronote_sync.sources.pronote.fallback import PronoteFetcher + + # Créer des settings avec pronotepy configuré mais sans ent + settings = Settings( + pronote=PronoteSettings( + pronote_url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent=None, # Explicitement None + agenda_source="pronotepy", + homework_source="pronotepy", + ), + app=Settings().app, + ) + + client: _MockPronoteClientProtocol = MagicMock() + fetcher = PronoteFetcher(settings=settings, pronote_client=client) + + # Should return True even without ent + assert fetcher._is_pronotepy_configured() is True + + +def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None: + """Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy. + + Ce test valide que le mode auto peut utiliser pronotepy même sans ent configuré. + + :return: None + """ + from pronote_sync.config.settings import PronoteSettings, Settings + from pronote_sync.sources.pronote.fallback import PronoteFetcher + + settings = Settings( + pronote=PronoteSettings( + pronote_url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent=None, # Explicitement None + ical_url=None, # Pas de iCal URL + agenda_source="auto", + ), + app=Settings().app, + ) + + client: _MockPronoteClientProtocol = MagicMock() + fetcher = PronoteFetcher(settings=settings, pronote_client=client) + + primary, fallback = fetcher._agenda_sources() + + assert primary == "pronotepy" + assert fallback is None + + +def test_homework_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None: + """Test _homework_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy. + + Ce test valide que le mode auto peut utiliser pronotepy pour les devoirs même sans ent configuré. + + :return: None + """ + from pronote_sync.config.settings import PronoteSettings, Settings + from pronote_sync.sources.pronote.fallback import PronoteFetcher + + settings = Settings( + pronote=PronoteSettings( + pronote_url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent=None, # Explicitement None + ical_url=None, # Pas de iCal URL + homework_source="auto", + ), + app=Settings().app, + ) + + client: _MockPronoteClientProtocol = MagicMock() + fetcher = PronoteFetcher(settings=settings, pronote_client=client) + + primary, fallback = fetcher._homework_sources() + + assert primary == "pronotepy" + assert fallback is None + + # Ensure trailing newline diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index 248c6b0..ce1d41e 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -309,7 +309,9 @@ def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) -> """ client = PronoteClient(empty_pronote_settings) - with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"): + with pytest.raises( + ValueError, match="pronote_url, username et password sont requis pour pronotepy" + ): client._connect() @@ -365,6 +367,132 @@ def test_connect_parent_account_type( 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( + pronote_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 pronote_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 pronote_url + settings = PronoteSettings( + pronote_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 "pronote_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( + pronote_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 "pronote_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( + pronote_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 "pronote_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: @@ -398,6 +526,57 @@ def test_connect_student_account_type( 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( + pronote_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: