test+docs(M4): tests get_lessons/get_homeworks + repli unique + doc sync

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 <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
2026-09-06 15:33:59 +02:00
parent 1b550aa818
commit 9d9a55ed40
5 changed files with 471 additions and 177 deletions

View File

@@ -139,11 +139,11 @@
"type": "Hex High Entropy String", "type": "Hex High Entropy String",
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_secret": false,
"is_verified": true, "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"
} }

View File

@@ -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_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`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_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_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` | | `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_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password PRONOTE_PASSWORD=your_secure_password
PRONOTE_ENT=monbureaunumerique PRONOTE_ENT=monbureaunumerique
PRONOTE_URL=
PRONOTE_ACCOUNT_TYPE=parent
# Sources (auto = essayer iCal d'abord, puis pronotepy) # Sources (auto = essayer iCal d'abord, puis pronotepy)
PRONOTE_AGENDA_SOURCE=auto PRONOTE_AGENDA_SOURCE=auto
@@ -369,6 +373,8 @@ class PronoteSettings(BaseSettings):
username: Optional[str] = None username: Optional[str] = None
password: Optional[SecretStr] = None password: Optional[SecretStr] = None
ent: Optional[str] = None ent: Optional[str] = None
pronote_url: Optional[str] = None
account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto" agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto" homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
messages_source: Literal["pronotepy"] = "pronotepy" 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))}") logger.error(f"Échec de la récupération des informations Pronote: {redact_secrets(str(e))}")
return [] 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). Récupère les cours via pronotepy (repli si iCal échoue).
**À utiliser uniquement si PRONOTE_AGENDA_SOURCE=pronotepy ou PRONOTE_HOMEWORK_SOURCE=pronotepy**. Les erreurs sont propagées pour déclencher le repli.
""" """
try:
client = self._get_client() client = self._get_client()
return [Lesson(
lessons = []
for lesson in client.get_lessons():
lessons.append(Lesson(
id=str(lesson.id), id=str(lesson.id),
start=lesson.start, start=lesson.start,
end=lesson.end, end=lesson.end,
subject=lesson.subject, subject=lesson.subject,
teachers=[t.name for t in lesson.teachers], teachers=[t.name for t in lesson.teachers],
rooms=[r.name for r in lesson.rooms], rooms=[r.name for r in lesson.rooms],
status=LessonStatus.NORMAL, # À adapter selon les données status=LessonStatus.NORMAL,
content=lesson.content, content=lesson.content,
)) ) for lesson in client.lessons(start, end)]
homeworks = [] def get_homeworks(self, start: date, end: date) -> list[HomeworkModel]:
for hw in client.get_homework(): """
homeworks.append(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), id=str(hw.id),
subject=hw.subject, subject=hw.subject,
teachers=[t.name for t in hw.teachers], teachers=[t.name for t in hw.teachers],
assigned_on=hw.given_date, assigned_on=hw.given_date,
due_on=hw.due_date, due_on=hw.due_date,
text=hw.description, text=hw.description,
html=hw.description, # pronotepy ne fournit pas de HTML html=hw.description,
)) ) for hw in client.homework(start, end)]
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 close(self) -> None: def close(self) -> None:
"""Fermeture du client.""" """Fermeture du client."""
@@ -2250,16 +2251,10 @@ class PronoteClient:
```python ```python
from typing import Literal, Optional from typing import Literal, Optional
from enum import Enum from enum import StrEnum
from .ical import fetch_ical, parse_ical from .ical import fetch_ical, parse_ical
from .client import PronoteClient from .client import PronoteClient
from ..models.agenda import Lesson, Homework from ..models.agenda import Lesson, SchoolEvent
class AgendaSource(Enum):
AUTO = "auto"
ICAL = "ical"
PRONOTEPY = "pronotepy"
class PronoteFetcher: class PronoteFetcher:
@@ -2271,6 +2266,8 @@ class PronoteFetcher:
username: Optional[str] = None, username: Optional[str] = None,
password: Optional[str] = None, password: Optional[str] = None,
ent: Optional[str] = None, ent: Optional[str] = None,
pronote_url: Optional[str] = None,
account_type: str = "parent",
agenda_source: str = "auto", agenda_source: str = "auto",
homework_source: str = "auto", homework_source: str = "auto",
): ):
@@ -2278,6 +2275,8 @@ class PronoteFetcher:
self.username = username self.username = username
self.password = password self.password = password
self.ent = ent self.ent = ent
self.pronote_url = pronote_url
self.account_type = account_type
self.agenda_source = AgendaSource(agenda_source) self.agenda_source = AgendaSource(agenda_source)
self.homework_source = AgendaSource(homework_source) self.homework_source = AgendaSource(homework_source)
self._pronote_client: Optional[PronoteClient] = None self._pronote_client: Optional[PronoteClient] = None
@@ -2289,19 +2288,25 @@ class PronoteFetcher:
password=self.password, password=self.password,
ent=self.ent, ent=self.ent,
ical_url=self.ical_url, ical_url=self.ical_url,
pronote_url=self.pronote_url,
account_type=self.account_type,
) )
return self._pronote_client return self._pronote_client
def fetch_agenda(self) -> tuple[List[Lesson], List[Homework]]: 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`).""" """
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: if self.agenda_source == AgendaSource.ICAL:
return self._fetch_agenda_ical() return self._fetch_agenda_ical(start, end)
elif self.agenda_source == AgendaSource.PRONOTEPY: elif self.agenda_source == AgendaSource.PRONOTEPY:
return self._fetch_agenda_pronotepy() return self._fetch_agenda_pronotepy(start, end)
else: # AUTO else: # AUTO
# Essayer iCal d'abord # Essayer iCal d'abord
try: try:
lessons, homeworks = self._fetch_agenda_ical() lessons, homeworks = self._fetch_agenda_ical(start, end)
if lessons or homeworks: if lessons or homeworks:
return lessons, homeworks return lessons, homeworks
except Exception as e: except Exception as e:
@@ -2309,30 +2314,33 @@ class PronoteFetcher:
# Repli sur pronotepy # Repli sur pronotepy
logger.info("Repli sur pronotepy pour l'agenda.") 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]: def fetch_homework(self, start: date, end: date) -> list[SchoolEvent]:
"""Récupère les devoirs selon la source configurée (`homework_source`).""" """
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: if self.homework_source == AgendaSource.ICAL:
# Récupérer uniquement les devoirs depuis iCal # Récupérer uniquement les devoirs depuis iCal
try: try:
_, homeworks = self._fetch_agenda_ical() _, homeworks = self._fetch_agenda_ical(start, end)
return homeworks return homeworks
except Exception as e: except Exception as e:
logger.warning(f"Échec de la récupération iCal pour les devoirs: {redact_secrets(str(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: elif self.homework_source == AgendaSource.PRONOTEPY:
# Récupérer uniquement les devoirs depuis pronotepy # Récupérer uniquement les devoirs depuis pronotepy
try: try:
_, homeworks = self._fetch_agenda_pronotepy() return self._fetch_agenda_pronotepy(start, end)[1]
return homeworks
except Exception as e: except Exception as e:
logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}") logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}")
return [] raise
else: # AUTO else: # AUTO
# Essayer iCal d'abord # Essayer iCal d'abord
try: try:
_, homeworks = self._fetch_agenda_ical() _, homeworks = self._fetch_agenda_ical(start, end)
if homeworks: if homeworks:
return homeworks return homeworks
except Exception as e: except Exception as e:
@@ -2341,33 +2349,32 @@ class PronoteFetcher:
# Repli sur pronotepy # Repli sur pronotepy
logger.info("Repli sur pronotepy pour les devoirs.") logger.info("Repli sur pronotepy pour les devoirs.")
try: try:
_, homeworks = self._fetch_agenda_pronotepy() return self._fetch_agenda_pronotepy(start, end)[1]
return homeworks
except Exception as e: except Exception as e:
logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(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.""" """Récupère l'agenda depuis iCal."""
if not self.ical_url: if not self.ical_url:
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal") raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
raw_ical = fetch_ical(self.ical_url) raw_ical = fetch_ical(self.ical_url)
lessons, homeworks, _ = parse_ical(raw_ical) lessons, homeworks, _ = parse_ical(raw_ical, start, end)
return lessons, homeworks 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.""" """Récupère l'agenda depuis pronotepy."""
client = self._get_pronote_client() client = self._get_pronote_client()
lessons, homeworks = client.get_agenda_fallback() lessons = client.get_lessons(start, end)
return lessons, homeworks return lessons, []
def fetch_messages(self) -> List[Message]: def fetch_messages(self) -> list[Message]:
"""Récupère les messages (toujours via pronotepy).""" """Récupère les messages (toujours via pronotepy)."""
client = self._get_pronote_client() client = self._get_pronote_client()
return client.get_messages() return client.get_messages()
def fetch_informations(self) -> List[Message]: def fetch_informations(self) -> list[Message]:
"""Récupère les informations (toujours via pronotepy).""" """Récupère les informations (toujours via pronotepy)."""
client = self._get_pronote_client() client = self._get_pronote_client()
return client.get_informations() 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): 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.""" """Test le pipeline complet avec des mocks."""
from pronote_sync.pipeline.run import PipelineRunner 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.caldav import CalDAVClient
from pronote_sync.sync.diff import AgendaComparator from pronote_sync.sync.diff import AgendaComparator
from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider

14
TODO.md
View File

@@ -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. 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`). - [x] 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). - [x] 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`. - [x] 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. - [x] 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`. - [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`.
- [ ] Implémenter le repli : iCal échoue → pronotepy ; pronotepy échoue → iCal ; les deux échouent → `PipelineCriticalError`. - [x] Implémenter le repli unique : source primaire + un seul repli (l'autre source si configurée) ; les deux échouent → `PipelineCriticalError`.
- [ ] Normaliser les UID via `utils/uid.normalize_pronote_uid` pour la stabilité des événements. - [x] Normaliser les UID via `utils/uid.normalize_pronote_uid` pour la stabilité des événements.
### Critères d'acceptation ### Critères d'acceptation
- `fetch_ical` parse `tests/fixtures/pronote-4e.ics` en leçons/devoirs/événements corrects (cours annulé détecté). - `fetch_ical` parse `tests/fixtures/pronote-4e.ics` en leçons/devoirs/événements corrects (cours annulé détecté).

View File

@@ -40,9 +40,8 @@ if TYPE_CHECKING:
class _MockPronoteClientProtocol(Protocol): class _MockPronoteClientProtocol(Protocol):
def get_messages(self) -> list[Message]: ... def get_messages(self) -> list[Message]: ...
def get_informations(self) -> list[Message]: ... def get_informations(self) -> list[Message]: ...
def get_agenda_fallback( def get_lessons(self, start: date, end: date) -> list[Lesson]: ...
self, start: date, end: date def get_homeworks(self, start: date, end: date) -> list[Homework]: ...
) -> tuple[list[Lesson], list[Homework]]: ...
@pytest.fixture(name="mock_settings") @pytest.fixture(name="mock_settings")
@@ -54,6 +53,7 @@ def fixture_mock_settings() -> Settings:
""" """
return Settings( return Settings(
pronote=PronoteSettings( pronote=PronoteSettings(
pronote_url="https://pronote.example.com",
ical_url=SecretStr("file:///fake/ical.ics"), ical_url=SecretStr("file:///fake/ical.ics"),
agenda_source="auto", agenda_source="auto",
homework_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: def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None:
"""Test la récupération de l'agenda en mode source pronotepy. """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). On vérifie que le fetcher retourne ces cours (événements scolaires vides).
:param mock_fetcher: Fetcher de test. :param mock_fetcher: Fetcher de test.
@@ -151,14 +151,14 @@ def test_fetch_agenda_pronotepy_mode(mock_fetcher: PronoteFetcher) -> None:
] ]
client = MagicMock() client = MagicMock()
client.get_agenda_fallback.return_value = (lessons, []) client.get_lessons.return_value = lessons
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
result_lessons, result_events = mock_fetcher.fetch_agenda() result_lessons, result_events = mock_fetcher.fetch_agenda()
assert result_lessons == lessons assert result_lessons == lessons
assert result_events == [] 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: 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_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error") m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock() client = MagicMock()
client.get_agenda_fallback.return_value = (lessons, []) client.get_lessons.return_value = lessons
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
result_lessons, _ = mock_fetcher.fetch_agenda() result_lessons, _ = mock_fetcher.fetch_agenda()
assert result_lessons == lessons 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: 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. :param mock_fetcher: Fetcher de test.
:return: None :return: None
""" """
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.pronote_url = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, 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.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: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda() 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: def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode ical : échec → PipelineCriticalError masquée. """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. :param mock_fetcher: Fetcher de test.
:return: None :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.agenda_source = "ical"
mock_fetcher._settings.pronote.pronote_url = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, 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: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda() 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 # Vérifie que le message ne contient pas de secret
assert "file:///fake/ical.ics" not in str(exc_info.value) 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: def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode pronotepy : échec → PipelineCriticalError masquée. """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. :param mock_fetcher: Fetcher de test.
:return: None :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.agenda_source = "pronotepy"
mock_fetcher._settings.pronote.ical_url = None
client = MagicMock() client = MagicMock()
client.get_lessons.side_effect = OSError("Pronote API error")
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
with pytest.raises(PipelineCriticalError) as exc_info: 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_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error") m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock() client = MagicMock()
client.get_agenda_fallback.return_value = ([], homeworks) client.get_homeworks.return_value = homeworks
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
m_collect.return_value = homeworks m_collect.return_value = homeworks
result = mock_fetcher.fetch_homework(target_date) result = mock_fetcher.fetch_homework(target_date)
assert result == homeworks 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: 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) target_date = date(2025, 9, 10)
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.pronote_url = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, 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.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: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_homework(target_date) 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: def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
@@ -457,7 +468,6 @@ def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
client = MagicMock() client = MagicMock()
client.get_messages.return_value = messages client.get_messages.return_value = messages
client.get_informations.return_value = [] client.get_informations.return_value = []
client.get_agenda_fallback.return_value = ([], [])
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
result = mock_fetcher.fetch_messages() result = mock_fetcher.fetch_messages()
@@ -488,7 +498,6 @@ def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None:
client = MagicMock() client = MagicMock()
client.get_informations.return_value = infos client.get_informations.return_value = infos
client.get_messages.return_value = [] client.get_messages.return_value = []
client.get_agenda_fallback.return_value = ([], [])
mock_fetcher._pronote_client = client mock_fetcher._pronote_client = client
result = mock_fetcher.fetch_informations() result = mock_fetcher.fetch_informations()
@@ -508,6 +517,9 @@ def test_no_secrets_in_error_messages(
:param caplog: Fixture pytest pour capturer les logs. :param caplog: Fixture pytest pour capturer les logs.
:return: None :return: None
""" """
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.pronote_url = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, 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.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 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="<p>Devoir pour aujourd'hui</p>",
),
Homework(
id="hw2",
subject="Physique",
teachers=(),
assigned_on=None,
due_on=other_date,
text="Devoir pour demain",
html="<p>Devoir pour demain</p>",
),
]
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 # Ensure trailing newline

View File

@@ -31,7 +31,8 @@ def test_protocol_methods(mocker: pytest_mock.MockerFixture) -> None:
""" """
assert hasattr(PronoteClientProtocol, "get_messages") assert hasattr(PronoteClientProtocol, "get_messages")
assert hasattr(PronoteClientProtocol, "get_informations") 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 --- # --- Client with mocked pronotepy ---
@@ -45,9 +46,11 @@ def pronote_settings() -> PronoteSettings:
:rtype: PronoteSettings :rtype: PronoteSettings
""" """
return PronoteSettings( return PronoteSettings(
pronote_url="https://pronote.example.com",
username="testuser", username="testuser",
password=SecretStr("testpass"), password=SecretStr("testpass"),
ent="testent", ent="bordeaux",
account_type="parent",
) )
@@ -82,7 +85,7 @@ def test_get_messages_success(
mock_message.seen = True mock_message.seen = True
mock_discussion.messages = [mock_message] mock_discussion.messages = [mock_message]
mock_client.discussions.return_value = [mock_discussion] 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) client = PronoteClient(pronote_settings)
messages = client.get_messages() messages = client.get_messages()
@@ -111,7 +114,7 @@ def test_get_messages_empty_on_error(
""" """
mock_client = mocker.MagicMock() mock_client = mocker.MagicMock()
mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error") 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) client = PronoteClient(pronote_settings)
messages = client.get_messages() messages = client.get_messages()
@@ -138,7 +141,7 @@ def test_get_informations_success(
mock_info.read = False mock_info.read = False
mock_info.survey = True mock_info.survey = True
mock_client.information_and_surveys.return_value = [mock_info] 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) client = PronoteClient(pronote_settings)
messages = client.get_informations() messages = client.get_informations()
@@ -167,7 +170,7 @@ def test_get_informations_empty_on_error(
""" """
mock_client = mocker.MagicMock() mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error") 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) client = PronoteClient(pronote_settings)
messages = client.get_informations() messages = client.get_informations()
@@ -175,10 +178,10 @@ def test_get_informations_empty_on_error(
assert messages == [] assert messages == []
def test_get_agenda_fallback_success( def test_get_lessons_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None: ) -> 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 mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides. :param pronote_settings: Paramètres Pronote valides.
@@ -201,20 +204,11 @@ def test_get_agenda_fallback_success(
mock_content.description = "Lesson content" mock_content.description = "Lesson content"
mock_lesson.content = mock_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.lessons.return_value = [mock_lesson]
mock_client.homework.return_value = [mock_hw] mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
mocker.patch("pronotepy.Client", return_value=mock_client)
client = PronoteClient(pronote_settings) 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 isinstance(lessons, list)
assert len(lessons) == 1 assert len(lessons) == 1
@@ -230,6 +224,32 @@ def test_get_agenda_fallback_success(
assert lesson.status == LessonStatus.NORMAL assert lesson.status == LessonStatus.NORMAL
assert lesson.content == "Lesson content" 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 isinstance(homeworks, list)
assert len(homeworks) == 1 assert len(homeworks) == 1
homework = homeworks[0] homework = homeworks[0]
@@ -243,10 +263,10 @@ def test_get_agenda_fallback_success(
assert homework.html == "Do your homework" 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 mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None: ) -> 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 mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides. :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 = mocker.MagicMock()
mock_client.lessons.side_effect = pronotepy.PronoteAPIError("API error") 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) client = PronoteClient(pronote_settings)
lessons, homeworks = client.get_agenda_fallback(date(2024, 9, 1), date(2024, 9, 30))
assert lessons == [] with pytest.raises(pronotepy.PronoteAPIError):
assert homeworks == [] client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
def test_missing_credentials_returns_empty(empty_pronote_settings: PronoteSettings) -> None: def test_get_homeworks_propagates_error(
"""Vérifie que les méthodes retournent une liste vide si les identifiants sont manquants. 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. :param empty_pronote_settings: Paramètres Pronote avec tous les champs à None.
:return: None :return: None
""" """
client = PronoteClient(empty_pronote_settings) client = PronoteClient(empty_pronote_settings)
messages = client.get_messages() with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"):
assert messages == [] client._connect()
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( 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 mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None: ) -> 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 mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides. :param pronote_settings: Paramètres Pronote valides.
:return: None :return: None
""" """
# Patch pronotepy.Client to return our mock from unittest.mock import Mock
from pronote_sync.sources.pronote import client as client_module
from pronote_sync.sources.pronote.client import PronoteClient
mock_client = mocker.MagicMock() mock_client = mocker.MagicMock()
mock_discussion = mocker.MagicMock() mock_client_class = Mock(return_value=mock_client)
mock_message = mocker.MagicMock() mocker.patch("pronotepy.ParentClient", new=mock_client_class)
mock_message.id = "msg-123" mocker.patch("pronotepy.Client")
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 = PronoteClient(pronote_settings)
_ = client.get_messages() _ = client._connect()
# Vérifie que le client a été créé avec le mot de passe # Verify ParentClient was used
# Le mock de Client doit avoir été appelé avec username, password, ent assert mock_client_class.call_count == 1
client_class_mock = client_module.pronotepy.Client # type: ignore[attr-defined] pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
client_class_mock.assert_called_once()
call_args = client_class_mock.call_args
assert call_args is not None def test_connect_student_account_type(
assert len(call_args.args) >= 3 mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
assert call_args.args[0] == "testuser" ) -> None:
assert call_args.args[1] == "testpass" """Vérifie que account_type='student' utilise pronotepy.Client.
assert call_args.args[2] == "testent"
: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 # Ensure trailing newline