"""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)