Implémente la synchronisation des événements Pronote vers un calendrier CalDAV (Nextcloud) de façon idempotente et sécurisée. Production : - sync/serialization.py : sérialisation Lesson/Homework/SchoolEvent vers VEVENT, signature sémantique (exclut DTSTAMP/CREATED/LAST-MODIFIED), enveloppe VCALENDAR complète avec VERSION:2.0 et PRODID - sync/caldav.py : passerelle CalDAV isolant caldav>=1.3.0, résolution du calendrier via principal().calendars() avec boundary matching, upsert par UID (fetch-then-save), exceptions expurgées et __context__ propre, mot de passe non stocké en clair, context manager - sync/planner.py : calcul explicite du CalDAVSyncPlan (add/update/remove par comparaison de signatures sémantiques, routage par préfixe d'UID) - sync/executor.py : exécution du plan avec dry-run (aucune écriture), isolation des erreurs par événement, statut FAILED/SKIPPED/SUCCESS - sync/synchronizer.py : orchestration en trois phases (scan, plan, exécution), SKIPPED si CalDAV non configuré - sync/__init__.py : export synchronize() - sources/pronote/client.py : normalisation UID via normalize_pronote_uid/ generate_deterministic_uid (parité avec ical.py) - config/settings.py : CalDAVSettings durci (url SecretStr, validation HTTPS, allow_insecure_http pour localhost, serializer redact_url) Tests (381 passés, couverture 95.58%) : - tests/unit/test_sync_serialization.py (21 tests) - tests/unit/test_caldav_planner.py (16 tests) - tests/unit/test_caldav_executor.py (18 tests) - tests/unit/test_caldav_gateway.py (24 tests) - tests/unit/test_caldav_security.py (18 tests) - tests/unit/test_uid_equivalence.py (8 tests) - tests/integration/test_caldav_sync.py (11 tests, faux serveur en mémoire) - tests/conftest.py : fixtures partagées Documentation : - GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0, principal().calendars(), VCALENDAR complet, upsert par UID, pas d'état local, événements non gérés protégés, CalDAVSettings durci (SecretStr, HTTPS, allow_insecure_http) - TODO.md : M7 coché - .env.example : CALDAV_ALLOW_INSECURE_HTTP=false Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
489 lines
14 KiB
Python
489 lines
14 KiB
Python
"""Tests unitaires pour la sérialisation des modèles Pronote en VEVENT iCalendar.
|
|
|
|
Ce module vérifie que les fonctions de conversion des modèles Pronote
|
|
(:class:`Lesson`, :class:`Homework`, :class:`SchoolEvent`) en composants
|
|
:class:`icalendar.Event` produisent les propriétés attendues (UID, SUMMARY,
|
|
DTSTART, DTEND, STATUS, CATEGORIES, etc.) et que la signature sémantique
|
|
déterministe est correctement calculée.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
|
|
from icalendar import Calendar, Event
|
|
|
|
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
|
from pronote_sync.models.homework import Homework
|
|
from pronote_sync.sync.serialization import (
|
|
MANAGED_PROPERTY,
|
|
MANAGED_VALUE,
|
|
component_to_signature,
|
|
homework_to_vevent,
|
|
lesson_to_vevent,
|
|
model_to_vcalendar_text,
|
|
school_event_to_vevent,
|
|
)
|
|
|
|
# --- Helper fixtures ---
|
|
|
|
|
|
def _make_lesson(
|
|
lesson_id: str = "L-1234",
|
|
subject: str = "Mathématiques",
|
|
start: datetime | None = None,
|
|
end: datetime | None = None,
|
|
status: LessonStatus = LessonStatus.NORMAL,
|
|
teachers: tuple[str, ...] = ("Prof Dupont",),
|
|
rooms: tuple[str, ...] = ("Salle 101",),
|
|
content: str | None = None,
|
|
group: str | None = None,
|
|
) -> Lesson:
|
|
"""Fabrique un cours Pronote pour les tests.
|
|
|
|
:param lesson_id: Identifiant du cours.
|
|
:param subject: Matière.
|
|
:param start: Date/heure de début.
|
|
:param end: Date/heure de fin.
|
|
:param status: Statut du cours.
|
|
:param teachers: Professeurs.
|
|
:param rooms: Salles.
|
|
:param content: Contenu pédagogique.
|
|
:param group: Groupe.
|
|
:return: Instance de Lesson.
|
|
:rtype: Lesson
|
|
"""
|
|
if start is None:
|
|
start = datetime(2026, 1, 15, 8, 0)
|
|
if end is None:
|
|
end = datetime(2026, 1, 15, 9, 0)
|
|
return Lesson(
|
|
id=lesson_id,
|
|
start=start,
|
|
end=end,
|
|
subject=subject,
|
|
teachers=teachers,
|
|
rooms=rooms,
|
|
status=status,
|
|
content=content,
|
|
group=group,
|
|
)
|
|
|
|
|
|
def _make_homework(
|
|
homework_id: str = "HW-5678",
|
|
subject: str = "Mathématiques",
|
|
due_on: date | None = None,
|
|
text: str = "Exercice 1 à 5",
|
|
teachers: tuple[str, ...] = ("Prof Dupont",),
|
|
assigned_on: date | None = None,
|
|
) -> Homework:
|
|
"""Fabrique un devoir Pronote pour les tests.
|
|
|
|
:param homework_id: Identifiant du devoir.
|
|
:param subject: Matière.
|
|
:param due_on: Date d'échéance.
|
|
:param text: Texte du devoir.
|
|
:param teachers: Professeurs.
|
|
:param assigned_on: Date de distribution.
|
|
:return: Instance de Homework.
|
|
:rtype: Homework
|
|
"""
|
|
if due_on is None:
|
|
due_on = date(2026, 1, 20)
|
|
return Homework(
|
|
id=homework_id,
|
|
subject=subject,
|
|
due_on=due_on,
|
|
text=text,
|
|
teachers=teachers,
|
|
assigned_on=assigned_on,
|
|
)
|
|
|
|
|
|
def _make_school_event(
|
|
label: str = "Vacances de Noël",
|
|
from_date: date | None = None,
|
|
to_date: date | None = None,
|
|
kind: SchoolEventKind = SchoolEventKind.HOLIDAY,
|
|
) -> SchoolEvent:
|
|
"""Fabrique un événement scolaire pour les tests.
|
|
|
|
:param label: Libellé de l'événement.
|
|
:param from_date: Date de début.
|
|
:param to_date: Date de fin.
|
|
:param kind: Type d'événement.
|
|
:return: Instance de SchoolEvent.
|
|
:rtype: SchoolEvent
|
|
"""
|
|
if from_date is None:
|
|
from_date = date(2026, 12, 20)
|
|
if to_date is None:
|
|
to_date = date(2027, 1, 5)
|
|
return SchoolEvent(
|
|
label=label,
|
|
from_date=from_date,
|
|
to_date=to_date,
|
|
kind=kind,
|
|
)
|
|
|
|
|
|
# --- lesson_to_vevent tests ---
|
|
|
|
|
|
def test_model_to_vcalendar_text_produces_complete_vcalendar() -> None:
|
|
"""Vérifie que la sérialisation produit un VCALENDAR complet avec un VEVENT.
|
|
|
|
:return: None
|
|
"""
|
|
lesson = _make_lesson()
|
|
output = model_to_vcalendar_text(lesson)
|
|
calendar = Calendar.from_ical(output)
|
|
|
|
assert "VERSION:2.0" in output
|
|
assert "PRODID" in output
|
|
assert output.startswith("BEGIN:VCALENDAR")
|
|
|
|
vevents = calendar.walk("VEVENT")
|
|
assert len(vevents) == 1
|
|
|
|
|
|
def test_lesson_to_vevent_normal() -> None:
|
|
"""Vérifie qu'un cours normal produit un VEVENT avec les bonnes propriétés.
|
|
|
|
Un cours normal doit avoir :
|
|
- UID = id du cours
|
|
- SUMMARY = matière
|
|
- DTSTART/DTEND = dates de début/fin
|
|
- STATUS = CONFIRMED
|
|
- CATEGORIES contient "Pronote"
|
|
- MANAGED_PROPERTY présent avec MANAGED_VALUE
|
|
"""
|
|
lesson = _make_lesson()
|
|
event = lesson_to_vevent(lesson)
|
|
|
|
assert str(event.get("UID")) == "L-1234"
|
|
assert str(event.get("SUMMARY")) == "Mathématiques"
|
|
assert event.get("DTSTART").dt == datetime(2026, 1, 15, 8, 0)
|
|
assert event.get("DTEND").dt == datetime(2026, 1, 15, 9, 0)
|
|
assert str(event.get("STATUS")) == "CONFIRMED"
|
|
|
|
categories = event.get("CATEGORIES")
|
|
assert categories is not None
|
|
assert "Pronote" in categories.cats
|
|
|
|
assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE
|
|
|
|
|
|
def test_lesson_to_vevent_cancelled() -> None:
|
|
"""Vérifie qu'un cours annulé a STATUS=CANCELLED et CATEGORIES contient 'Annulé'.
|
|
|
|
:return: None
|
|
"""
|
|
lesson = _make_lesson(status=LessonStatus.CANCELLED)
|
|
event = lesson_to_vevent(lesson)
|
|
|
|
assert str(event.get("STATUS")) == "CANCELLED"
|
|
categories = event.get("CATEGORIES")
|
|
assert categories is not None
|
|
assert "Pronote" in categories.cats
|
|
assert "Annulé" in categories.cats
|
|
|
|
|
|
def test_lesson_to_vevent_moved() -> None:
|
|
"""Vérifie qu'un cours déplacé a CATEGORIES contient 'Déplacé'.
|
|
|
|
:return: None
|
|
"""
|
|
lesson = _make_lesson(status=LessonStatus.MOVED)
|
|
event = lesson_to_vevent(lesson)
|
|
|
|
assert str(event.get("STATUS")) == "CONFIRMED"
|
|
categories = event.get("CATEGORIES")
|
|
assert categories is not None
|
|
assert "Pronote" in categories.cats
|
|
assert "Déplacé" in categories.cats
|
|
|
|
|
|
def test_lesson_to_vevent_description() -> None:
|
|
"""Vérifie que la description contient tous les champs renseignés.
|
|
|
|
:return: None
|
|
"""
|
|
lesson = _make_lesson(
|
|
subject="Mathématiques",
|
|
teachers=("Prof Dupont", "Prof Martin"),
|
|
rooms=("Salle 101", "Salle 102"),
|
|
content="Chapitre 1",
|
|
)
|
|
event = lesson_to_vevent(lesson)
|
|
description = str(event.get("DESCRIPTION"))
|
|
|
|
assert "Matière: Mathématiques" in description
|
|
assert "Professeur(s): Prof Dupont, Prof Martin" in description
|
|
assert "Salle(s): Salle 101, Salle 102" in description
|
|
assert "Contenu: Chapitre 1" in description
|
|
|
|
|
|
# --- homework_to_vevent tests ---
|
|
|
|
|
|
def test_homework_to_vevent_uid_prefix() -> None:
|
|
"""Vérifie que l'UID d'un devoir est préfixé par 'homework-'.
|
|
|
|
:return: None
|
|
"""
|
|
homework = _make_homework()
|
|
event = homework_to_vevent(homework)
|
|
|
|
assert str(event.get("UID")) == "homework-HW-5678"
|
|
|
|
|
|
def test_homework_to_vevent_status() -> None:
|
|
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
|
|
|
|
:return: None
|
|
"""
|
|
homework = _make_homework()
|
|
event = homework_to_vevent(homework)
|
|
|
|
assert str(event.get("STATUS")) == "NEEDS-ACTION"
|
|
|
|
|
|
def test_homework_to_vevent_categories() -> None:
|
|
"""Vérifie que les CATEGORIES d'un devoir contiennent 'Pronote' et 'Devoir'.
|
|
|
|
:return: None
|
|
"""
|
|
homework = _make_homework()
|
|
event = homework_to_vevent(homework)
|
|
|
|
categories = event.get("CATEGORIES")
|
|
assert categories is not None
|
|
assert "Pronote" in categories.cats
|
|
assert "Devoir" in categories.cats
|
|
|
|
|
|
def test_homework_to_vevent_dtstart_dtend() -> None:
|
|
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
|
|
|
|
:return: None
|
|
"""
|
|
homework = _make_homework(due_on=date(2026, 1, 20))
|
|
event = homework_to_vevent(homework)
|
|
|
|
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
|
|
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
|
|
|
|
|
|
def test_homework_to_vevent_summary() -> None:
|
|
"""Vérifie que le SUMMARY d'un devoir est préfixé par 'Devoir: '.
|
|
|
|
:return: None
|
|
"""
|
|
homework = _make_homework(subject="Mathématiques")
|
|
event = homework_to_vevent(homework)
|
|
|
|
assert str(event.get("SUMMARY")) == "Devoir: Mathématiques"
|
|
|
|
|
|
def test_homework_to_vevent_managed_marker() -> None:
|
|
"""Vérifie que le marqueur MANAGED_PROPERTY est présent.
|
|
|
|
:return: None
|
|
"""
|
|
homework = _make_homework()
|
|
event = homework_to_vevent(homework)
|
|
|
|
assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE
|
|
|
|
|
|
# --- school_event_to_vevent tests ---
|
|
|
|
|
|
def test_school_event_to_vevent_uid_prefix() -> None:
|
|
"""Vérifie que l'UID d'un événement scolaire est préfixé correctement.
|
|
|
|
:return: None
|
|
"""
|
|
school_event = _make_school_event(
|
|
label="Vacances de Noël",
|
|
from_date=date(2026, 12, 20),
|
|
)
|
|
event = school_event_to_vevent(school_event)
|
|
|
|
assert str(event.get("UID")) == "school-event-Vacances de Noël-2026-12-20"
|
|
|
|
|
|
def test_school_event_to_vevent_status() -> None:
|
|
"""Vérifie qu'un événement scolaire a STATUS=CONFIRMED.
|
|
|
|
:return: None
|
|
"""
|
|
school_event = _make_school_event()
|
|
event = school_event_to_vevent(school_event)
|
|
|
|
assert str(event.get("STATUS")) == "CONFIRMED"
|
|
|
|
|
|
def test_school_event_to_vevent_categories() -> None:
|
|
"""Vérifie que les CATEGORIES contiennent 'Pronote' et la valeur du kind.
|
|
|
|
:return: None
|
|
"""
|
|
school_event = _make_school_event(kind=SchoolEventKind.HOLIDAY)
|
|
event = school_event_to_vevent(school_event)
|
|
|
|
categories = event.get("CATEGORIES")
|
|
assert categories is not None
|
|
assert "Pronote" in categories.cats
|
|
assert "holiday" in categories.cats
|
|
|
|
|
|
def test_school_event_to_vevent_dtstart_dtend() -> None:
|
|
"""Vérifie que DTSTART et DTEND sont des vDate (pas vDatetime).
|
|
|
|
:return: None
|
|
"""
|
|
school_event = _make_school_event(
|
|
from_date=date(2026, 12, 20),
|
|
to_date=date(2027, 1, 5),
|
|
)
|
|
event = school_event_to_vevent(school_event)
|
|
|
|
from icalendar import vDate
|
|
|
|
assert isinstance(event.get("DTSTART"), vDate)
|
|
assert isinstance(event.get("DTEND"), vDate)
|
|
assert event.get("DTSTART").dt == date(2026, 12, 20)
|
|
assert event.get("DTEND").dt == date(2027, 1, 5)
|
|
|
|
|
|
def test_school_event_to_vevent_managed_marker() -> None:
|
|
"""Vérifie que le marqueur MANAGED_PROPERTY est présent.
|
|
|
|
:return: None
|
|
"""
|
|
school_event = _make_school_event()
|
|
event = school_event_to_vevent(school_event)
|
|
|
|
assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE
|
|
|
|
|
|
# --- component_to_signature tests ---
|
|
|
|
|
|
def test_component_to_signature_ignores_volatile_properties() -> None:
|
|
"""Vérifie que deux VEVENTs ne différant que par DTSTAMP/CREATED/LAST-MODIFIED/SEQUENCE
|
|
produisent la même signature.
|
|
|
|
:return: None
|
|
"""
|
|
# Créer deux événements identiques sauf pour les propriétés volatiles
|
|
event1 = Event()
|
|
event1.add("UID", "test-uid")
|
|
event1.add("SUMMARY", "Test Event")
|
|
event1.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
|
event1.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
|
event1.add("STATUS", "CONFIRMED")
|
|
event1.add("DTSTAMP", datetime(2026, 1, 1, 0, 0)) # Différent
|
|
event1.add("CREATED", datetime(2026, 1, 1, 0, 0)) # Différent
|
|
|
|
event2 = Event()
|
|
event2.add("UID", "test-uid")
|
|
event2.add("SUMMARY", "Test Event")
|
|
event2.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
|
event2.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
|
event2.add("STATUS", "CONFIRMED")
|
|
event2.add("DTSTAMP", datetime(2026, 1, 2, 0, 0)) # Différent
|
|
event2.add("LAST-MODIFIED", datetime(2026, 1, 2, 0, 0)) # Différent
|
|
event2.add("SEQUENCE", 1) # Différent
|
|
|
|
sig1 = component_to_signature(event1)
|
|
sig2 = component_to_signature(event2)
|
|
|
|
assert sig1 == sig2
|
|
|
|
|
|
def test_component_to_signature_different_summary() -> None:
|
|
"""Vérifie que deux VEVENTs avec SUMMARY différent produisent des signatures différentes.
|
|
|
|
:return: None
|
|
"""
|
|
event1 = Event()
|
|
event1.add("UID", "test-uid")
|
|
event1.add("SUMMARY", "Event 1")
|
|
event1.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
|
event1.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
|
event1.add("STATUS", "CONFIRMED")
|
|
|
|
event2 = Event()
|
|
event2.add("UID", "test-uid")
|
|
event2.add("SUMMARY", "Event 2") # Différent
|
|
event2.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
|
event2.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
|
event2.add("STATUS", "CONFIRMED")
|
|
|
|
sig1 = component_to_signature(event1)
|
|
sig2 = component_to_signature(event2)
|
|
|
|
assert sig1 != sig2
|
|
|
|
|
|
def test_component_to_signature_deterministic() -> None:
|
|
"""Vérifie que la signature est déterministe (même entrée → même sortie).
|
|
|
|
:return: None
|
|
"""
|
|
event = Event()
|
|
event.add("UID", "test-uid")
|
|
event.add("SUMMARY", "Test Event")
|
|
event.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
|
event.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
|
event.add("STATUS", "CONFIRMED")
|
|
|
|
sig1 = component_to_signature(event)
|
|
sig2 = component_to_signature(event)
|
|
|
|
assert sig1 == sig2
|
|
|
|
|
|
def test_component_to_signature_includes_categories() -> None:
|
|
"""Vérifie que les CATEGORIES sont incluses dans la signature (triées).
|
|
|
|
:return: None
|
|
"""
|
|
event = Event()
|
|
event.add("UID", "test-uid")
|
|
event.add("SUMMARY", "Test Event")
|
|
event.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
|
event.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
|
event.add("STATUS", "CONFIRMED")
|
|
event.add("CATEGORIES", ["Pronote", "Devoir"])
|
|
|
|
sig = component_to_signature(event)
|
|
|
|
# Les catégories doivent apparaître dans la signature
|
|
assert "categories=devoir,pronote" in sig
|
|
|
|
|
|
def test_component_to_signature_includes_managed_property() -> None:
|
|
"""Vérifie que MANAGED_PROPERTY est incluse dans la signature.
|
|
|
|
:return: None
|
|
"""
|
|
event = Event()
|
|
event.add("UID", "test-uid")
|
|
event.add("SUMMARY", "Test Event")
|
|
event.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
|
event.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
|
event.add("STATUS", "CONFIRMED")
|
|
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
|
|
|
sig = component_to_signature(event)
|
|
|
|
assert f"managed={MANAGED_VALUE.lower()}" in sig
|
|
|
|
|
|
# Ensure trailing newline
|