"""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:
Matière : Math\nProfesseur : M. Dupont\nSalle : 204\nContenu pédagogique :Résoudre des équations.
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="Exercice 1 à 5 page 42
",
),
),
)
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="Exercice 1 à 5 page 42
",
),
),
)
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="Devoir commun
",
),
),
)
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="Devoir commun
",
),
),
)
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="Devoir B
"
),
),
)
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="Devoir A
"
),
),
)
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 = " Exercice 1 à 5
\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:
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:
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:
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:
Matière : Math
Professeur : M. Dupont
Salle : 204
Pour le 10/09/2026 :
Exercice 1 à 5 page 42.
Pour le 10/09/2026 :
Exercice 6 à 10 page 43.
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 = (
"\n"
" Pour le 10/09/2026:\n"
" Exercice 1 à 5 page 42.\n"
" Pour le 10/09/2026:\n"
" Exercice 6 à 10 page 43.\n"
"
"
)
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)