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

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

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.
- [ ] 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é).

View File

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

View File

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