- 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>
466 lines
13 KiB
Python
466 lines
13 KiB
Python
"""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
|