feat(M7): synchronisation différentielle CalDAV

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>
This commit is contained in:
2026-09-07 09:24:18 +02:00
parent ebbe39f1f0
commit b4b0247919
21 changed files with 5042 additions and 151 deletions

View File

@@ -0,0 +1,488 @@
"""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