From 9d9a55ed4071c81e13fe6f9737552f9ea6f477c9 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sun, 6 Sep 2026 15:33:59 +0200 Subject: [PATCH] test+docs(M4): tests get_lessons/get_homeworks + repli unique + doc sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: - test_pronote_client : get_lessons/get_homeworks success + error propagation, ENT resolution, factory parent/student, mode dégradé messages/informations (ConnectionError, TimeoutError) - test_fallback : repli unique ICAL→pronotepy et PRONOTEPY→iCal, filtre target_date, fetch_messages log + re-raise sans secrets, aucune source configurée → PipelineCriticalError - 59 tests passent (16 client + 17 fallback + 26 iCal) Documentation: - TODO.md : 7 items M4 cochés, contrat repli corrigé (unique, non réciproque) selon décision D4 - GUIDE_DEV_PYTHON.md : get_agenda_fallback → get_lessons/get_homeworks, pronote_url/account_type ajoutés au modèle + env vars + .env.example, AgendaSource StrEnum, §5.1.8 fallback contract mis à jour (repli unique) Co-authored-by: opencode/test-engineer Co-authored-by: opencode/tech-writer --- .secrets.baseline | 6 +- GUIDE_DEV_PYTHON.md | 141 ++++++++-------- TODO.md | 14 +- tests/unit/test_fallback.py | 230 +++++++++++++++++++++++--- tests/unit/test_pronote_client.py | 257 ++++++++++++++++++++---------- 5 files changed, 471 insertions(+), 177 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index fbd8007..0383b7c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -139,11 +139,11 @@ "type": "Hex High Entropy String", "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", - "is_secret": false, "is_verified": true, - "line_number": 5117 + "line_number": 5124, + "is_secret": false } ] }, - "generated_at": "2026-09-05T21:51:55Z" + "generated_at": "2026-09-06T13:36:29Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 602a76f..d7bbd0d 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -285,6 +285,8 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati | `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` | | `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` | | `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` | +| `PRONOTE_URL` | URL de l'instance Pronote. | `None` | `str \| None` | +| `PRONOTE_ACCOUNT_TYPE` | Type de compte (`student` ou `parent`). | `parent` | `Literal` | | `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` | | `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` | @@ -309,6 +311,8 @@ PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPL PRONOTE_USERNAME=parent.dupont PRONOTE_PASSWORD=your_secure_password PRONOTE_ENT=monbureaunumerique +PRONOTE_URL= +PRONOTE_ACCOUNT_TYPE=parent # Sources (auto = essayer iCal d'abord, puis pronotepy) PRONOTE_AGENDA_SOURCE=auto @@ -369,6 +373,8 @@ class PronoteSettings(BaseSettings): username: Optional[str] = None password: Optional[SecretStr] = None ent: Optional[str] = None + pronote_url: Optional[str] = None + account_type: Literal["student", "parent"] = "parent" agenda_source: Literal["auto", "ical", "pronotepy"] = "auto" homework_source: Literal["auto", "ical", "pronotepy"] = "auto" messages_source: Literal["pronotepy"] = "pronotepy" @@ -2201,43 +2207,38 @@ class PronoteClient: logger.error(f"Échec de la récupération des informations Pronote: {redact_secrets(str(e))}") return [] - def get_agenda_fallback(self) -> tuple[List[Lesson], List[HomeworkModel]]: + def get_lessons(self, start: date, end: date) -> list[Lesson]: """ - Récupère l'agenda et les devoirs via pronotepy (repli si iCal échoue). - **À utiliser uniquement si PRONOTE_AGENDA_SOURCE=pronotepy ou PRONOTE_HOMEWORK_SOURCE=pronotepy**. + Récupère les cours via pronotepy (repli si iCal échoue). + Les erreurs sont propagées pour déclencher le repli. """ - try: - client = self._get_client() + client = self._get_client() + return [Lesson( + id=str(lesson.id), + start=lesson.start, + end=lesson.end, + subject=lesson.subject, + teachers=[t.name for t in lesson.teachers], + rooms=[r.name for r in lesson.rooms], + status=LessonStatus.NORMAL, + content=lesson.content, + ) for lesson in client.lessons(start, end)] - lessons = [] - for lesson in client.get_lessons(): - lessons.append(Lesson( - id=str(lesson.id), - start=lesson.start, - end=lesson.end, - subject=lesson.subject, - teachers=[t.name for t in lesson.teachers], - rooms=[r.name for r in lesson.rooms], - status=LessonStatus.NORMAL, # À adapter selon les données - content=lesson.content, - )) - - homeworks = [] - for hw in client.get_homework(): - homeworks.append(HomeworkModel( - id=str(hw.id), - subject=hw.subject, - teachers=[t.name for t in hw.teachers], - assigned_on=hw.given_date, - due_on=hw.due_date, - text=hw.description, - html=hw.description, # pronotepy ne fournit pas de HTML - )) - - return lessons, homeworks - except PronoteAPIError as e: - logger.error(f"Échec de la récupération de l'agenda via pronotepy: {redact_secrets(str(e))}") - return [], [] + def get_homeworks(self, start: date, end: date) -> list[HomeworkModel]: + """ + Récupère les devoirs via pronotepy. + Les erreurs sont propagées pour déclencher le repli. + """ + client = self._get_client() + return [HomeworkModel( + id=str(hw.id), + subject=hw.subject, + teachers=[t.name for t in hw.teachers], + assigned_on=hw.given_date, + due_on=hw.due_date, + text=hw.description, + html=hw.description, + ) for hw in client.homework(start, end)] def close(self) -> None: """Fermeture du client.""" @@ -2250,16 +2251,10 @@ class PronoteClient: ```python from typing import Literal, Optional -from enum import Enum +from enum import StrEnum from .ical import fetch_ical, parse_ical from .client import PronoteClient -from ..models.agenda import Lesson, Homework - - -class AgendaSource(Enum): - AUTO = "auto" - ICAL = "ical" - PRONOTEPY = "pronotepy" +from ..models.agenda import Lesson, SchoolEvent class PronoteFetcher: @@ -2271,6 +2266,8 @@ class PronoteFetcher: username: Optional[str] = None, password: Optional[str] = None, ent: Optional[str] = None, + pronote_url: Optional[str] = None, + account_type: str = "parent", agenda_source: str = "auto", homework_source: str = "auto", ): @@ -2278,6 +2275,8 @@ class PronoteFetcher: self.username = username self.password = password self.ent = ent + self.pronote_url = pronote_url + self.account_type = account_type self.agenda_source = AgendaSource(agenda_source) self.homework_source = AgendaSource(homework_source) self._pronote_client: Optional[PronoteClient] = None @@ -2289,19 +2288,25 @@ class PronoteFetcher: password=self.password, ent=self.ent, ical_url=self.ical_url, + pronote_url=self.pronote_url, + account_type=self.account_type, ) return self._pronote_client - def fetch_agenda(self) -> tuple[List[Lesson], List[Homework]]: - """Récupère l'agenda selon la source configurée (`agenda_source`).""" + def fetch_agenda(self, start: date, end: date) -> tuple[list[Lesson], list[SchoolEvent]]: + """ + Récupère l'agenda selon la source configurée (`agenda_source`). + En mode AUTO, essaie iCal d'abord, puis pronotepy. Si les deux échouent, lève une erreur explicite. + """ + from datetime import date if self.agenda_source == AgendaSource.ICAL: - return self._fetch_agenda_ical() + return self._fetch_agenda_ical(start, end) elif self.agenda_source == AgendaSource.PRONOTEPY: - return self._fetch_agenda_pronotepy() + return self._fetch_agenda_pronotepy(start, end) else: # AUTO # Essayer iCal d'abord try: - lessons, homeworks = self._fetch_agenda_ical() + lessons, homeworks = self._fetch_agenda_ical(start, end) if lessons or homeworks: return lessons, homeworks except Exception as e: @@ -2309,30 +2314,33 @@ class PronoteFetcher: # Repli sur pronotepy logger.info("Repli sur pronotepy pour l'agenda.") - return self._fetch_agenda_pronotepy() + return self._fetch_agenda_pronotepy(start, end) - def fetch_homework(self) -> List[Homework]: - """Récupère les devoirs selon la source configurée (`homework_source`).""" + def fetch_homework(self, start: date, end: date) -> list[SchoolEvent]: + """ + Récupère les devoirs selon la source configurée (`homework_source`). + En mode AUTO, essaie iCal d'abord, puis pronotepy. Si les deux échouent, lève une erreur explicite. + """ + from datetime import date if self.homework_source == AgendaSource.ICAL: # Récupérer uniquement les devoirs depuis iCal try: - _, homeworks = self._fetch_agenda_ical() + _, homeworks = self._fetch_agenda_ical(start, end) return homeworks except Exception as e: logger.warning(f"Échec de la récupération iCal pour les devoirs: {redact_secrets(str(e))}") - return [] + raise elif self.homework_source == AgendaSource.PRONOTEPY: # Récupérer uniquement les devoirs depuis pronotepy try: - _, homeworks = self._fetch_agenda_pronotepy() - return homeworks + return self._fetch_agenda_pronotepy(start, end)[1] except Exception as e: logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}") - return [] + raise else: # AUTO # Essayer iCal d'abord try: - _, homeworks = self._fetch_agenda_ical() + _, homeworks = self._fetch_agenda_ical(start, end) if homeworks: return homeworks except Exception as e: @@ -2341,33 +2349,32 @@ class PronoteFetcher: # Repli sur pronotepy logger.info("Repli sur pronotepy pour les devoirs.") try: - _, homeworks = self._fetch_agenda_pronotepy() - return homeworks + return self._fetch_agenda_pronotepy(start, end)[1] except Exception as e: logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}") - return [] + raise - def _fetch_agenda_ical(self) -> tuple[List[Lesson], List[Homework]]: + def _fetch_agenda_ical(self, start: date, end: date) -> tuple[list[Lesson], list[SchoolEvent]]: """Récupère l'agenda depuis iCal.""" if not self.ical_url: raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal") raw_ical = fetch_ical(self.ical_url) - lessons, homeworks, _ = parse_ical(raw_ical) + lessons, homeworks, _ = parse_ical(raw_ical, start, end) return lessons, homeworks - def _fetch_agenda_pronotepy(self) -> tuple[List[Lesson], List[Homework]]: + def _fetch_agenda_pronotepy(self, start: date, end: date) -> tuple[list[Lesson], list[SchoolEvent]]: """Récupère l'agenda depuis pronotepy.""" client = self._get_pronote_client() - lessons, homeworks = client.get_agenda_fallback() - return lessons, homeworks + lessons = client.get_lessons(start, end) + return lessons, [] - def fetch_messages(self) -> List[Message]: + def fetch_messages(self) -> list[Message]: """Récupère les messages (toujours via pronotepy).""" client = self._get_pronote_client() return client.get_messages() - def fetch_informations(self) -> List[Message]: + def fetch_informations(self) -> list[Message]: """Récupère les informations (toujours via pronotepy).""" client = self._get_pronote_client() return client.get_informations() @@ -5347,7 +5354,7 @@ def test_parse_ical_homework(parsed_lessons): def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider, mock_xmpp_channel, sample_settings): """Test le pipeline complet avec des mocks.""" from pronote_sync.pipeline.run import PipelineRunner - from pronote_sync.sources.pronote.fetcher import PronoteFetcher + from pronote_sync.sources.pronote.fallback import PronoteFetcher from pronote_sync.sync.caldav import CalDAVClient from pronote_sync.sync.diff import AgendaComparator from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider diff --git a/TODO.md b/TODO.md index 2db2b4a..efa2fb4 100644 --- a/TODO.md +++ b/TODO.md @@ -73,13 +73,13 @@ Définir tous les modèles de domaine, immuables pour les contrats, mutables pou Récupérer et normaliser l'agenda, les devoirs et les messages Pronote, avec repli entre iCal et pronotepy. -- [ ] Créer `sources/pronote/ical.py` : `fetch_ical(url)` (HTTP via `requests`, erreurs redactées) et parsing iCal → `Lesson`/`Homework`/`SchoolEvent` (`icalendar`). -- [ ] Extraire les blocs de devoirs (`HomeworkBlock`) depuis `DESCRIPTION` et dédupliquer les devoirs (clé normalisée par date). -- [ ] Détecter les statuts (`CANCELLED`/`MOVED`) via `CATEGORIES` et `STATUS:CANCELLED`. -- [ ] Créer `sources/pronote/client.py` : client `pronotepy` (messages, informations, discussions, sondages, et devoirs en repli) avec masquage des erreurs. -- [ ] Créer `sources/pronote/fallback.py` : sélection de source selon `PRONOTE_*_SOURCE` (auto/ical/pronotepy) et `PronoteFetcher` unifiant `fetch_agenda`/`fetch_homework`/`fetch_messages`. -- [ ] Implémenter le repli : iCal échoue → pronotepy ; pronotepy échoue → iCal ; les deux échouent → `PipelineCriticalError`. -- [ ] Normaliser les UID via `utils/uid.normalize_pronote_uid` pour la stabilité des événements. +- [x] Créer `sources/pronote/ical.py` : `fetch_ical(url)` (HTTP via `requests`, erreurs redactées) et parsing iCal → `Lesson`/`Homework`/`SchoolEvent` (`icalendar`). +- [x] Extraire les blocs de devoirs (`HomeworkBlock`) depuis `DESCRIPTION` et dédupliquer les devoirs (clé normalisée par date). +- [x] Détecter les statuts (`CANCELLED`/`MOVED`) via `CATEGORIES` et `STATUS:CANCELLED`. +- [x] Créer `sources/pronote/client.py` : client `pronotepy` (messages, informations, discussions, sondages, et devoirs en repli) avec masquage des erreurs. +- [x] Créer `sources/pronote/fallback.py` : sélection de source selon `PRONOTE_*_SOURCE` (auto/ical/pronotepy) et `PronoteFetcher` unifiant `fetch_agenda`/`fetch_homework`/`fetch_messages`. +- [x] Implémenter le repli unique : source primaire + un seul repli (l'autre source si configurée) ; les deux échouent → `PipelineCriticalError`. +- [x] Normaliser les UID via `utils/uid.normalize_pronote_uid` pour la stabilité des événements. ### Critères d'acceptation - `fetch_ical` parse `tests/fixtures/pronote-4e.ics` en leçons/devoirs/événements corrects (cours annulé détecté). diff --git a/tests/unit/test_fallback.py b/tests/unit/test_fallback.py index e81613c..4ed65d8 100644 --- a/tests/unit/test_fallback.py +++ b/tests/unit/test_fallback.py @@ -40,9 +40,8 @@ if TYPE_CHECKING: 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]]: ... + 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") @@ -54,6 +53,7 @@ def fixture_mock_settings() -> Settings: """ return Settings( pronote=PronoteSettings( + pronote_url="https://pronote.example.com", ical_url=SecretStr("file:///fake/ical.ics"), agenda_source="auto", homework_source="auto", @@ -128,7 +128,7 @@ def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None: 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 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. @@ -151,14 +151,14 @@ def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None: ] client = MagicMock() - client.get_agenda_fallback.return_value = (lessons, []) + 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_agenda_fallback.assert_called_once() + client.get_lessons.assert_called_once() def test_fetch_agenda_auto_ical_success(mock_fetcher: PronoteFetcher) -> None: @@ -228,13 +228,13 @@ def test_fetch_agenda_auto_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) - 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, []) + client.get_lessons.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() + client.get_lessons.assert_called_once() def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: @@ -245,6 +245,9 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: :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, @@ -257,19 +260,21 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_agenda() - assert "iCal et pronotepy" in str(exc_info.value) + 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. On vérifie que l'erreur brute est masquée dans la levé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 + # 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, @@ -283,7 +288,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None: 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) + 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) @@ -291,15 +296,18 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None: 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. + 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 + # 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: @@ -399,14 +407,14 @@ def test_fetch_homework_auto_fallback(mock_fetcher: PronoteFetcher) -> None: 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) + 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_agenda_fallback.assert_called_once() + client.get_homeworks.assert_called_once() def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: @@ -419,6 +427,9 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> 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, @@ -431,7 +442,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None: with pytest.raises(PipelineCriticalError) as exc_info: mock_fetcher.fetch_homework(target_date) - assert "iCal et pronotepy" in str(exc_info.value) + assert "la source ical a échoué" in str(exc_info.value) def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None: @@ -457,7 +468,6 @@ def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None: 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() @@ -488,7 +498,6 @@ def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None: 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() @@ -508,6 +517,9 @@ def test_no_secrets_in_error_messages( :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, @@ -529,4 +541,184 @@ def test_no_secrets_in_error_messages( 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 + ) + + # Ensure trailing newline diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index 7a134e4..248c6b0 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -31,7 +31,8 @@ def test_protocol_methods(mocker: pytest_mock.MockerFixture) -> None: """ assert hasattr(PronoteClientProtocol, "get_messages") assert hasattr(PronoteClientProtocol, "get_informations") - assert hasattr(PronoteClientProtocol, "get_agenda_fallback") + assert hasattr(PronoteClientProtocol, "get_lessons") + assert hasattr(PronoteClientProtocol, "get_homeworks") # --- Client with mocked pronotepy --- @@ -45,9 +46,11 @@ def pronote_settings() -> PronoteSettings: :rtype: PronoteSettings """ return PronoteSettings( + pronote_url="https://pronote.example.com", username="testuser", password=SecretStr("testpass"), - ent="testent", + ent="bordeaux", + account_type="parent", ) @@ -82,7 +85,7 @@ def test_get_messages_success( mock_message.seen = True mock_discussion.messages = [mock_message] mock_client.discussions.return_value = [mock_discussion] - mocker.patch("pronotepy.Client", return_value=mock_client) + mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) client = PronoteClient(pronote_settings) messages = client.get_messages() @@ -111,7 +114,7 @@ def test_get_messages_empty_on_error( """ mock_client = mocker.MagicMock() mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error") - mocker.patch("pronotepy.Client", return_value=mock_client) + mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) client = PronoteClient(pronote_settings) messages = client.get_messages() @@ -138,7 +141,7 @@ def test_get_informations_success( 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) + mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) client = PronoteClient(pronote_settings) messages = client.get_informations() @@ -167,7 +170,7 @@ def test_get_informations_empty_on_error( """ mock_client = mocker.MagicMock() mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error") - mocker.patch("pronotepy.Client", return_value=mock_client) + mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) client = PronoteClient(pronote_settings) messages = client.get_informations() @@ -175,10 +178,10 @@ def test_get_informations_empty_on_error( assert messages == [] -def test_get_agenda_fallback_success( +def test_get_lessons_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. + """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. @@ -201,20 +204,11 @@ def test_get_agenda_fallback_success( 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) + mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) client = PronoteClient(pronote_settings) - lessons, homeworks = client.get_agenda_fallback(date(2024, 9, 1), date(2024, 9, 30)) + lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30)) assert isinstance(lessons, list) assert len(lessons) == 1 @@ -230,6 +224,32 @@ def test_get_agenda_fallback_success( 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] @@ -243,10 +263,10 @@ def test_get_agenda_fallback_success( assert homework.html == "Do your homework" -def test_get_agenda_fallback_empty_on_error( +def test_get_lessons_propagates_error( mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings ) -> None: - """Vérifie que get_agenda_fallback retourne des listes vides en cas d'erreur API. + """Vérifie que get_lessons propage les exceptions API. :param mocker: Fixture pytest-mock pour le mocking. :param pronote_settings: Paramètres Pronote valides. @@ -254,91 +274,166 @@ def test_get_agenda_fallback_empty_on_error( """ mock_client = mocker.MagicMock() mock_client.lessons.side_effect = pronotepy.PronoteAPIError("API error") - mocker.patch("pronotepy.Client", return_value=mock_client) + mocker.patch.object(PronoteClient, "_connect", 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 == [] + with pytest.raises(pronotepy.PronoteAPIError): + client.get_lessons(date(2024, 9, 1), date(2024, 9, 30)) -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. +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) - 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 == [] + with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"): + client._connect() -def test_password_used_in_connection( +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 le mot de passe est bien utilisé pour la connexion. + """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 """ - # Patch pronotepy.Client to return our mock - from pronote_sync.sources.pronote import client as client_module + from unittest.mock import Mock + + from pronote_sync.sources.pronote.client import PronoteClient 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) + 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.get_messages() + _ = client._connect() - # 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" + # 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