Configuration (D1+D3) : - PronoteSettings : ajout pronote_url (str | None) et account_type (Literal student/parent, défaut parent) Parsing iCal (Fix 4+7) : - parse_ical : détection STATUS:CANCELLED en plus de CATEGORIES - parse_body : list[tuple[date, str]] au lieu de dict[date, str] pour préserver les blocs multiples à la même date - parse_homework_blocks : adapté aux listes Co-authored-by: opencode/coder <coder@agents.invalid>
601 lines
17 KiB
Python
601 lines
17 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_body,
|
|
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
|
|
|
|
|
|
CANCELLED_STATUS_ICAL = """BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
X-WR-CALNAME:Test
|
|
BEGIN:VEVENT
|
|
UID:Test-123-20260906T120000Z-Index-Education
|
|
DTSTART:20260907T080000Z
|
|
DTEND:20260907T090000Z
|
|
SUMMARY:Test Course
|
|
STATUS:CANCELLED
|
|
DESCRIPTION:<div></div>
|
|
END:VEVENT
|
|
END:VCALENDAR"""
|
|
|
|
|
|
NORMAL_STATUS_ICAL = """BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
X-WR-CALNAME:Test
|
|
BEGIN:VEVENT
|
|
UID:Test-123-20260906T120000Z-Index-Education
|
|
DTSTART:20260907T080000Z
|
|
DTEND:20260907T090000Z
|
|
SUMMARY:Test Course
|
|
STATUS:CONFIRMED
|
|
DESCRIPTION:<div></div>
|
|
END:VEVENT
|
|
END:VCALENDAR"""
|
|
|
|
|
|
MOVED_BY_CATEGORY_ICAL = """BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
X-WR-CALNAME:Test
|
|
BEGIN:VEVENT
|
|
UID:Test-123-20260906T120000Z-Index-Education
|
|
DTSTART:20260907T080000Z
|
|
DTEND:20260907T090000Z
|
|
SUMMARY:Test Course
|
|
CATEGORIES:Cours - Cours déplacé
|
|
DESCRIPTION:<div></div>
|
|
END:VEVENT
|
|
END:VCALENDAR"""
|
|
|
|
|
|
MULTIPLE_BLOCKS_SAME_DATE_ICAL = """BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
X-WR-CALNAME:Test
|
|
BEGIN:VEVENT
|
|
UID:Test-123-20260906T120000Z-Index-Education
|
|
DTSTART:20260907T080000Z
|
|
DTEND:20260907T090000Z
|
|
SUMMARY:Test Course
|
|
CATEGORIES:Cours
|
|
DESCRIPTION:<div>
|
|
Matière : Math
|
|
Professeur : M. Dupont
|
|
Salle : 204
|
|
|
|
<strong>Pour le 10/09/2026 :</strong>
|
|
Exercice 1 à 5 page 42.
|
|
<strong>Pour le 10/09/2026 :</strong>
|
|
Exercice 6 à 10 page 43.
|
|
</div>
|
|
END:VEVENT
|
|
END:VCALENDAR"""
|
|
|
|
|
|
def test_parse_ical_status_cancelled_only() -> None:
|
|
"""Un cours avec STATUS:CANCELLED mais sans CATEGORIES contenant 'Cours annulé' a status == LessonStatus.CANCELLED.
|
|
|
|
:return: None
|
|
"""
|
|
lessons, _, _ = parse_ical(CANCELLED_STATUS_ICAL)
|
|
assert len(lessons) == 1
|
|
assert lessons[0].status == LessonStatus.CANCELLED
|
|
|
|
|
|
def test_parse_ical_status_normal_without_cancel() -> None:
|
|
"""Un cours avec STATUS:CONFIRMED (ou sans STATUS) a status == LessonStatus.NORMAL.
|
|
|
|
:return: None
|
|
"""
|
|
lessons, _, _ = parse_ical(NORMAL_STATUS_ICAL)
|
|
assert len(lessons) == 1
|
|
assert lessons[0].status == LessonStatus.NORMAL
|
|
|
|
|
|
def test_parse_ical_moved_by_category_only() -> None:
|
|
"""Un cours avec CATEGORIES:Cours - Cours déplacé et sans STATUS a status == LessonStatus.MOVED.
|
|
|
|
:return: None
|
|
"""
|
|
lessons, _, _ = parse_ical(MOVED_BY_CATEGORY_ICAL)
|
|
assert len(lessons) == 1
|
|
assert lessons[0].status == LessonStatus.MOVED
|
|
|
|
|
|
def test_parse_body_multiple_blocks_same_date() -> None:
|
|
"""Un DESCRIPTION avec deux sections 'Pour le' à la même date conserve les deux blocs.
|
|
|
|
:return: None
|
|
"""
|
|
body_html = (
|
|
"<div>\n"
|
|
" <strong>Pour le 10/09/2026:</strong>\n"
|
|
" Exercice 1 à 5 page 42.\n"
|
|
" <strong>Pour le 10/09/2026:</strong>\n"
|
|
" Exercice 6 à 10 page 43.\n"
|
|
"</div>"
|
|
)
|
|
content, due_blocks, assigned_blocks = parse_body(body_html)
|
|
assert len(due_blocks) == 2
|
|
assert due_blocks[0][0] == date(2026, 9, 10)
|
|
assert due_blocks[0][1] == "Exercice 1 à 5 page 42."
|
|
assert due_blocks[1][0] == date(2026, 9, 10)
|
|
assert due_blocks[1][1] == "Exercice 6 à 10 page 43."
|
|
|
|
|
|
def test_collect_homeworks_from_fixture() -> None:
|
|
"""Parse le fixture pronote-4e.ics, appelle collect_homeworks pour le 10/09/2026 et vérifie qu'au moins un devoir 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()
|
|
|
|
lessons, _, _ = parse_ical(content)
|
|
homeworks = collect_homeworks(lessons, date(2026, 9, 10))
|
|
|
|
assert len(homeworks) >= 1
|
|
# Vérifie qu'au moins un devoir a le bon sujet et texte
|
|
assert any(hw.subject == "Mathématiques" for hw in homeworks)
|
|
assert any("Exercices 1 à 5 page 42" in hw.text for hw in homeworks)
|