test(M4): tests unitaires iCal, client pronotepy et repli

- test_ical.py : 20 tests (fetch file://, parsing VEVENT, détection
  cours annulé, collect_homeworks avec déduplication, normalisation,
  génération d'ID déterministe, redaction des erreurs)
- test_pronote_client.py : 9 tests (Protocol, messages mockés,
  informations, agenda fallback, credentials manquants, sécurité
  mot de passe)
- test_fallback.py : 16 tests (modes ICAL/PRONOTEPY/AUTO, repli,
  PipelineCriticalError si les deux sources échouent, redaction
  des erreurs)
- pre-commit : ajout de responses et pytest-mock au hook mypy
- Total : 42 nouveaux tests M4 (122 tests au total sur le projet)

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 14:10:16 +02:00
parent f2be495f55
commit e0cd073f9a
4 changed files with 1342 additions and 1 deletions

532
tests/unit/test_fallback.py Normal file
View File

@@ -0,0 +1,532 @@
"""Tests unitaires pour la logique de repli iCal / pronotepy.
Ce module valide le comportement du module :mod:`pronote_sync.sources.pronote.fallback`
et de son implémentation :class:`PronoteFetcher`. Les tests couvrent :
- La sélection de la source d'agenda (``ical``, ``pronotepy``, ``auto``) et de devoirs,
- Le repli automatique iCal → pronotepy en mode ``auto``,
- La levée de :class:`PipelineCriticalError` lorsque toutes les sources échouent,
- Le masquage des secrets dans les messages d'erreur,
- Les méthodes toujours basées sur pronotepy (messages, informations).
Tous les appels réseau et les interactions avec pronotepy sont mockés.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from pydantic import SecretStr
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.errors import PipelineCriticalError
from pronote_sync.models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
)
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.sources.pronote.fallback import PronoteFetcher
if TYPE_CHECKING:
from typing import Protocol
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]]: ...
@pytest.fixture(name="mock_settings")
def fixture_mock_settings() -> Settings:
"""Fixture fournissant une configuration Settings adaptée aux tests.
:return: Instance de :class:`Settings` avec des valeurs sûres pour les tests.
:rtype: Settings
"""
return Settings(
pronote=PronoteSettings(
ical_url=SecretStr("file:///fake/ical.ics"),
agenda_source="auto",
homework_source="auto",
username="testuser",
password=SecretStr("testpass"),
ent="ent",
),
app=Settings().app,
)
@pytest.fixture(name="mock_fetcher")
def fixture_mock_fetcher(mock_settings: Settings) -> PronoteFetcher:
"""Fixture fournissant une instance de :class:`PronoteFetcher` prête à l'emploi.
:param mock_settings: Configuration de test.
:return: Instance de :class:`PronoteFetcher` pour les tests.
:rtype: PronoteFetcher
"""
client: _MockPronoteClientProtocol = MagicMock()
return PronoteFetcher(settings=mock_settings, pronote_client=client)
def test_fetch_agenda_ical_mode(mock_fetcher: PronoteFetcher) -> None:
"""Test la récupération de l'agenda en mode source iCal.
On mock ``fetch_ical`` et ``parse_ical`` pour retourner des cours et événements.
On vérifie que le fetcher retourne bien ces données.
: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,
)
]
events = [
SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Vacances",
from_date=date(2025, 9, 1),
to_date=date(2025, 9, 15),
)
]
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.return_value = "BEGIN:VCALENDAR\n..."
m_parse_ical.return_value = (lessons, [], events)
result_lessons, result_events = mock_fetcher.fetch_agenda()
assert result_lessons == lessons
assert result_events == events
m_fetch_ical.assert_called_once()
m_parse_ical.assert_called_once()
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 vérifie que le fetcher retourne ces cours (événements scolaires vides).
: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="Physique",
teachers=("Martin",),
rooms=("Labo1",),
group="1ereB",
status=LessonStatus.NORMAL,
content=None,
)
]
client = MagicMock()
client.get_agenda_fallback.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()
def test_fetch_agenda_auto_ical_success(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode auto : succès de l'iCal, pronotepy non appelé.
On mock iCal pour réussir, et on vérifie que pronotepy n'est pas sollicité.
: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,
)
]
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.return_value = "BEGIN:VCALENDAR\n..."
m_parse_ical.return_value = (lessons, [], [])
result_lessons, _ = mock_fetcher.fetch_agenda()
assert result_lessons == lessons
def test_fetch_agenda_auto_fallback_to_pronotepy(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode auto : é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="Histoire",
teachers=("Lefevre",),
rooms=("S3",),
group="2ndeD",
status=LessonStatus.NORMAL,
content=None,
)
]
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
client.get_agenda_fallback.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()
def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode auto : échec des deux sources → PipelineCriticalError.
On mock iCal et pronotepy pour échouer. On vérifie la levée de l'erreur critique.
:param mock_fetcher: Fetcher de test.
:return: None
"""
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
mock_fetcher._pronote_client = client
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
assert "iCal et pronotepy" 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.
:param mock_fetcher: Fetcher de test.
:return: 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(
"Impossible de lire le fichier iCal file:///fake/ical.ics : iCal unreachable"
)
m_parse_ical.side_effect = OSError("iCal parse error")
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)
# Vérifie que le message ne contient pas de secret
assert "file:///fake/ical.ics" not in str(exc_info.value)
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.
:param mock_fetcher: Fetcher de test.
:return: None
"""
# Override settings to use pronotepy mode explicitly
mock_fetcher._settings.pronote.agenda_source = "pronotepy"
client = MagicMock()
mock_fetcher._pronote_client = client
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
assert "Impossible de récupérer l'agenda : la source pronotepy a échoué" in str(exc_info.value)
def test_fetch_homework_ical_mode(mock_fetcher: PronoteFetcher) -> None:
"""Test la récupération des devoirs en mode source iCal.
On mock iCal pour retourner des cours avec blocs de devoirs, et on cible une date.
On vérifie que les devoirs sont correctement collectés.
:param mock_fetcher: Fetcher de test.
:return: None
"""
target_date = date(2025, 9, 5)
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,
homework_blocks=(
HomeworkBlock(
kind="due",
date=target_date,
text="Devoir sur les fonctions",
html="<p>Devoir sur les fonctions</p>",
),
),
)
]
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
):
m_fetch_ical.return_value = "BEGIN:VCALENDAR\n..."
m_parse_ical.return_value = (lessons, [], [])
m_collect.return_value = [
Homework(
id="hw1",
subject="Maths",
teachers=(),
assigned_on=None,
due_on=target_date,
text="Devoir sur les fonctions",
html="<p>Devoir sur les fonctions</p>",
)
]
result = mock_fetcher.fetch_homework(target_date)
assert len(result) == 1
assert result[0].subject == "Maths"
assert result[0].due_on == target_date
m_collect.assert_called_once_with(lessons, target_date)
def test_fetch_homework_auto_fallback(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode auto des devoirs : échec iCal, repli pronotepy.
On mock iCal pour échouer et pronotepy pour réussir. On vérifie que pronotepy est utilisé.
:param mock_fetcher: Fetcher de test.
:return: None
"""
target_date = date(2025, 9, 10)
homeworks = [
Homework(
id="hw1",
subject="Physique",
teachers=(),
assigned_on=None,
due_on=target_date,
text="TP à préparer",
html="TP à préparer",
)
]
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
client.get_agenda_fallback.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()
def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
"""Test le mode auto des devoirs : échec des deux sources → PipelineCriticalError.
On mock iCal et pronotepy pour échouer. On vérifie la levée de l'erreur critique.
:param mock_fetcher: Fetcher de test.
:return: None
"""
target_date = date(2025, 9, 10)
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()
mock_fetcher._pronote_client = client
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_homework(target_date)
assert "iCal et pronotepy" in str(exc_info.value)
def test_fetch_messages(mock_fetcher: PronoteFetcher) -> None:
"""Test la récupération des messages (toujours via pronotepy).
On mock ``get_messages`` du client pour retourner des messages typés.
On vérifie que le fetcher retourne ces messages.
:param mock_fetcher: Fetcher de test.
:return: None
"""
messages = [
Message(
id="m1",
type=MessageType.DISCUSSION,
title="Devoir de maths",
content="À faire pour demain",
author="M. Dupont",
date=datetime(2025, 9, 1, 10, 0),
read=False,
)
]
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()
assert result == messages
def test_fetch_informations(mock_fetcher: PronoteFetcher) -> None:
"""Test la récupération des informations (toujours via pronotepy).
On mock ``get_informations`` du client pour retourner des informations.
On vérifie que le fetcher retourne ces informations.
:param mock_fetcher: Fetcher de test.
:return: None
"""
infos = [
Message(
id="i1",
type=MessageType.INFORMATION,
title="Info rentrée",
content="Rappel des consignes",
author="CPE",
date=datetime(2025, 9, 1, 9, 0),
read=True,
)
]
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()
assert result == infos
def test_no_secrets_in_error_messages(
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None:
"""Test que les messages d'erreur ne contiennent pas de secrets.
On simule une erreur iCal contenant un token ``icalsecurise`` et on vérifie que le log
masqué ne contient pas le token.
:param mock_fetcher: Fetcher de test.
:param caplog: Fixture pytest pour capturer les logs.
:return: 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,
):
error_msg = (
"Impossible de lire le fichier iCal file:///ical?icalsecurise=SECRET_TOKEN_123 : "
"[Errno 2] No such file or directory"
)
m_fetch_ical.side_effect = OSError(error_msg)
m_parse_ical.side_effect = OSError("parse error")
client = MagicMock()
mock_fetcher._pronote_client = client
with pytest.raises(PipelineCriticalError):
mock_fetcher.fetch_agenda()
# Vérifie que le log contient la version masquée
assert "SECRET_TOKEN_123" not in caplog.text
assert "icalsecurise=REDACTED" in caplog.text or "icalsecurise" not in caplog.text
# Ensure trailing newline

465
tests/unit/test_ical.py Normal file
View File

@@ -0,0 +1,465 @@
"""Tests unitaires pour le module iCal : téléchargement et parsing.
Ce module teste :
- La récupération du flux iCal (file://, HTTP)
- Le parsing des événements en modèles Lesson, SchoolEvent
- L'extraction et normalisation des devoirs
- La collecte et déduplication des devoirs par date cible
Les tests utilisent des mocks pour éviter tout appel réseau réel.
"""
from __future__ import annotations
import tempfile
import urllib.parse
from datetime import date, datetime
from pathlib import Path
import pytest
import responses
from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus
from pronote_sync.sources.pronote.ical import (
collect_homeworks,
fetch_ical,
generate_homework_id,
get_calendar_name,
normalize_homework_text,
parse_ical,
)
@pytest.fixture
def valid_ical_content() -> str:
"""Contenu iCal valide pour tests de parsing."""
return """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//FR
X-WR-CALNAME:Test Calendar
BEGIN:VEVENT
UID:test-1@test.net
DTSTAMP:20260905T120000Z
DTSTART:20260905T080000Z
DTEND:20260905T090000Z
SUMMARY:Math
CATEGORIES:Cours
DESCRIPTION:<div>Matière : Math\nProfesseur : M. Dupont\nSalle : 204\n<strong>Contenu pédagogique :</strong>Résoudre des équations.</div>
END:VEVENT
END:VCALENDAR
"""
@pytest.fixture
def invalid_ical_content() -> str:
"""Contenu iCal invalide (sans BEGIN:VCALENDAR)."""
return "INVALID:CONTENT\nThis is not a valid iCal file."
def test_fetch_ical_file_protocol() -> None:
"""fetch_ical("file://tests/fixtures/pronote-4e.ics") retourne un contenu commençant par BEGIN:VCALENDAR.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
url = f"file://{fixture_path}"
content = fetch_ical(url)
assert content.lstrip().startswith("BEGIN:VCALENDAR")
def test_fetch_ical_file_uri_decoding() -> None:
"""fetch_ical("file://path%20with%20spaces") décode correctement le chemin.
:return: None
"""
# Créer un fichier temporaire avec un espace dans le nom
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir) / "fichier avec espaces.ics"
temp_path.write_text(
"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR",
encoding="utf-8",
)
# URL encodée avec espace
encoded_name = urllib.parse.quote("fichier avec espaces.ics")
url = f"file://{tmpdir}/{encoded_name}"
# Cela devrait fonctionner car Path.read_text décode l'URL
content = fetch_ical(url)
assert content.lstrip().startswith("BEGIN:VCALENDAR")
@responses.activate
def test_fetch_ical_invalid_content() -> None:
"""Si le contenu ne commence pas par BEGIN:VCALENDAR, une exception est levée.
:return: None
"""
responses.add(
responses.GET,
"https://example.com/ical.ics",
body="INVALID:CONTENT",
status=200,
)
with pytest.raises(ValueError, match="Flux iCal invalide"):
fetch_ical("https://example.com/ical.ics")
@responses.activate
def test_fetch_ical_http() -> None:
"""Mock de requests.get pour retourner un contenu iCal valide.
:return: None
"""
valid_content = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR"
responses.add(
responses.GET,
"https://pronote.example.com/ical.ics",
body=valid_content,
status=200,
)
content = fetch_ical("https://pronote.example.com/ical.ics")
assert content == valid_content
@responses.activate
def test_fetch_ical_redacts_errors() -> None:
"""Les messages d'erreur ne contiennent pas l'URL complète (doit être masquée).
:return: None
"""
responses.add(
responses.GET,
"https://pronote.example.com/ical.ics",
body=Exception("Erreur réseau"),
status=500,
)
with pytest.raises(Exception) as exc_info:
fetch_ical("https://pronote.example.com/ical.ics?token=secret123")
error_msg = str(exc_info.value)
# Vérifie que le token secret n'est pas dans le message
assert "secret123" not in error_msg
# Vérifie que l'URL est masquée (utilise redact_url)
assert "https://pronote.example.com/ical.ics" in error_msg
# Le message doit contenir la partie masquée
assert "...ics" in error_msg or "pronote.example.com/ical" in error_msg
def test_get_calendar_name() -> None:
"""get_calendar_name(raw_ical) retourne le nom du calendrier.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
name = get_calendar_name(content)
assert name == "Classe de 4e"
def test_get_calendar_name_with_params() -> None:
"""Test avec X-WR-CALNAME;LANGUAGE=fr:TestName.
:return: None
"""
raw_ical = """BEGIN:VCALENDAR
X-WR-CALNAME;LANGUAGE=fr:TestName
END:VCALENDAR
"""
name = get_calendar_name(raw_ical)
assert name == "TestName"
def test_get_calendar_name_none() -> None:
"""Retourne None si X-WR-CALNAME est absent.
:return: None
"""
raw_ical = """BEGIN:VCALENDAR
VERSION:2.0
END:VCALENDAR
"""
name = get_calendar_name(raw_ical)
assert name is None
def test_parse_ical_returns_lessons() -> None:
"""parse_ical(fixture_content) retourne au moins 2 cours.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
lessons, homeworks, school_events = parse_ical(content)
assert len(lessons) >= 2
def test_parse_ical_detects_cancelled_course() -> None:
"""Un cours a status == LessonStatus.CANCELLED.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
lessons, _, _ = parse_ical(content)
cancelled_lessons = [lesson for lesson in lessons if lesson.status == LessonStatus.CANCELLED]
assert len(cancelled_lessons) >= 1
def test_parse_ical_returns_school_events() -> None:
"""Au moins 1 événement scolaire est retourné.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
_, _, school_events = parse_ical(content)
assert len(school_events) >= 1
def test_parse_ical_homeworks_empty() -> None:
"""La liste des devoirs est toujours vide depuis parse_ical.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
_, homeworks, _ = parse_ical(content)
assert homeworks == []
def test_parse_ical_lesson_fields() -> None:
"""Vérifie qu'un cours a les bons champs (matière, profs, salles).
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, encoding="utf-8") as f:
content = f.read()
lessons, _, _ = parse_ical(content)
assert len(lessons) > 0
# Vérifions le premier cours (Mathématiques)
lesson = lessons[0]
assert lesson.subject == "Mathématiques"
assert lesson.teachers == ("M. Dupont",)
assert lesson.rooms == ("204",)
assert lesson.status == LessonStatus.NORMAL
def test_collect_homeworks_dedup() -> None:
"""Étant donné des cours avec des blocs de devoirs, collect_homeworks retourne des devoirs dédupliqués.
:return: None
"""
# Créer des cours avec des blocs de devoirs en double
lesson1 = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Dupont",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Exercice 1 à 5 page 42",
html="<p>Exercice 1 à 5 page 42</p>",
),
),
)
lesson2 = Lesson(
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Exercice 1 à 5 page 42",
html="<p>Exercice 1 à 5 page 42</p>",
),
),
)
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 1 # Un seul devoir dédupliqué
def test_collect_homeworks_id_stability() -> None:
"""Deux devoirs avec le même texte et date d'échéance produisent le même ID.
:return: None
"""
lesson1 = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Dupont",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Devoir commun",
html="<p>Devoir commun</p>",
),
),
)
lesson2 = Lesson(
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Devoir commun",
html="<p>Devoir commun</p>",
),
),
)
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 1
assert homeworks[0].id == generate_homework_id(date(2026, 9, 10), "devoir commun")
def test_collect_homeworks_sorted() -> None:
"""Les résultats sont triés par (subject.lower(), text.lower()).
:return: None
"""
lesson1 = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Zoologie",
teachers=("M. A",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due", date=date(2026, 9, 10), text="Devoir B", html="<p>Devoir B</p>"
),
),
)
lesson2 = Lesson(
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Mathématiques",
teachers=("M. B",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due", date=date(2026, 9, 10), text="Devoir A", html="<p>Devoir A</p>"
),
),
)
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 2
assert homeworks[0].subject == "Mathématiques"
assert homeworks[0].text == "Devoir A"
assert homeworks[1].subject == "Zoologie"
assert homeworks[1].text == "Devoir B"
def test_collect_homeworks_empty() -> None:
"""Aucun bloc de devoir → liste vide.
:return: None
"""
lesson = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Dupont",),
rooms=("204",),
group=None,
status=LessonStatus.NORMAL,
content=None,
)
homeworks = collect_homeworks([lesson], target_date=date(2026, 9, 10))
assert homeworks == []
def test_normalize_homework_text() -> None:
"""Vérifie la normalisation des espaces, suppression HTML et minuscules.
:return: None
"""
text = " <p>Exercice 1 à 5</p> \n\n page 42 "
normalized = normalize_homework_text(text)
assert normalized == "exercice 1 à 5 page 42"
def test_generate_homework_id_format() -> None:
"""Retourne un ID de 12 caractères hexadécimaux.
:return: None
"""
due_on = date(2026, 9, 10)
text = "devoir test"
homework_id = generate_homework_id(due_on, text)
assert len(homework_id) == 12
assert all(c in "0123456789abcdef" for c in homework_id)
def test_generate_homework_id_deterministic() -> None:
"""Mêmes entrées → même sortie.
:return: None
"""
due_on = date(2026, 9, 10)
text = "devoir commun"
id1 = generate_homework_id(due_on, text)
id2 = generate_homework_id(due_on, text)
assert id1 == id2

View File

@@ -0,0 +1,344 @@
"""Tests unitaires pour le client Pronote via pronotepy.
Ce module vérifie le comportement du client ``PronoteClient`` et de son
protocole ``PronoteClientProtocol``. Tous les tests sont unitaires et
utilisent des mocks pour éviter tout accès réseau réel à Pronote.
"""
from __future__ import annotations
from datetime import date, datetime
import pronotepy
import pytest
import pytest_mock
from pydantic import SecretStr
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.sources.pronote.client import PronoteClient, PronoteClientProtocol
# --- Protocol tests ---
def test_protocol_methods(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que le protocole PronoteClientProtocol expose les méthodes attendues.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
assert hasattr(PronoteClientProtocol, "get_messages")
assert hasattr(PronoteClientProtocol, "get_informations")
assert hasattr(PronoteClientProtocol, "get_agenda_fallback")
# --- Client with mocked pronotepy ---
@pytest.fixture
def pronote_settings() -> PronoteSettings:
"""Fournit des paramètres Pronote valides pour les tests.
:return: Instance de PronoteSettings avec des valeurs par défaut.
:rtype: PronoteSettings
"""
return PronoteSettings(
username="testuser",
password=SecretStr("testpass"),
ent="testent",
)
@pytest.fixture
def empty_pronote_settings() -> PronoteSettings:
"""Fournit des paramètres Pronote vides pour les tests.
:return: Instance de PronoteSettings avec tous les champs à None.
:rtype: PronoteSettings
"""
return PronoteSettings(username=None, password=None, ent=None)
def test_get_messages_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste de Message en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
# Mock du client pronotepy
mock_client = mocker.MagicMock()
mock_discussion = mocker.MagicMock()
mock_discussion.subject = "Test Subject"
mock_message = mocker.MagicMock()
mock_message.id = "msg-123"
mock_message.content = "Test message content"
mock_message.author = "Teacher Test"
mock_message.created = datetime(2024, 9, 1, 10, 0, 0)
mock_message.seen = True
mock_discussion.messages = [mock_message]
mock_client.discussions.return_value = [mock_discussion]
mocker.patch("pronotepy.Client", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert isinstance(messages, list)
assert len(messages) == 1
message = messages[0]
assert isinstance(message, Message)
assert message.id == "msg-123"
assert message.type == MessageType.DISCUSSION
assert message.title == "Test Subject"
assert message.content == "Test message content"
assert message.author == "Teacher Test"
assert message.date == datetime(2024, 9, 1, 10, 0, 0)
assert message.read is True
def test_get_messages_empty_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste vide en cas d'erreur API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch("pronotepy.Client", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert messages == []
def test_get_informations_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste de Message en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_info = mocker.MagicMock()
mock_info.id = "info-456"
mock_info.title = "Important Info"
mock_info.content.return_value = "Important content"
mock_info.author = "Admin"
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
mock_info.read = False
mock_info.survey = True
mock_client.information_and_surveys.return_value = [mock_info]
mocker.patch("pronotepy.Client", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
assert isinstance(messages, list)
assert len(messages) == 1
message = messages[0]
assert isinstance(message, Message)
assert message.id == "info-456"
assert message.type == MessageType.SURVEY
assert message.title == "Important Info"
assert message.content == "Important content"
assert message.author == "Admin"
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
assert message.read is False
def test_get_informations_empty_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste vide en cas d'erreur API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch("pronotepy.Client", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
assert messages == []
def test_get_agenda_fallback_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.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
# Mock des cours
mock_lesson = mocker.MagicMock()
mock_lesson.id = "lesson-789"
mock_lesson.start = datetime(2024, 9, 1, 8, 0, 0)
mock_lesson.end = datetime(2024, 9, 1, 9, 30, 0)
mock_lesson.subject = mocker.MagicMock()
mock_lesson.subject.name = "Maths"
mock_lesson.teacher_names = ["Prof A", "Prof B"]
mock_lesson.classrooms = ["Salle 101", "Salle 102"]
mock_lesson.group_name = "Classe 1"
mock_lesson.canceled = False
mock_content = mocker.MagicMock()
mock_content.description = "Lesson content"
mock_lesson.content = mock_content
# Mock 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)
client = PronoteClient(pronote_settings)
lessons, homeworks = client.get_agenda_fallback(date(2024, 9, 1), date(2024, 9, 30))
assert isinstance(lessons, list)
assert len(lessons) == 1
lesson = lessons[0]
assert isinstance(lesson, Lesson)
assert lesson.id == "lesson-789"
assert lesson.start == datetime(2024, 9, 1, 8, 0, 0)
assert lesson.end == datetime(2024, 9, 1, 9, 30, 0)
assert lesson.subject == "Maths"
assert lesson.teachers == ("Prof A", "Prof B")
assert lesson.rooms == ("Salle 101", "Salle 102")
assert lesson.group == "Classe 1"
assert lesson.status == LessonStatus.NORMAL
assert lesson.content == "Lesson content"
assert isinstance(homeworks, list)
assert len(homeworks) == 1
homework = homeworks[0]
assert isinstance(homework, Homework)
assert homework.id == "hw-101"
assert homework.subject == "Maths"
assert homework.teachers == ()
assert homework.assigned_on is None
assert homework.due_on == date(2024, 9, 15)
assert homework.text == "Do your homework"
assert homework.html == "Do your homework"
def test_get_agenda_fallback_empty_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_agenda_fallback retourne des listes vides en cas d'erreur API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.lessons.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch("pronotepy.Client", 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 == []
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.
: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 == []
def test_password_used_in_connection(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que le mot de passe est bien utilisé pour la connexion.
: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
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)
client = PronoteClient(pronote_settings)
_ = client.get_messages()
# 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"
# Ensure trailing newline