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,581 @@
"""Tests unitaires pour le planificateur de synchronisation CalDAV.
Ce module vérifie que la fonction :func:`compute_plan` produit correctement
les listes d'ajouts, mises à jour et suppressions pour chaque catégorie
(cours, devoirs, événements scolaires) en comparant les données Pronote
normalisées aux événements distants marqués comme gérés.
"""
from __future__ import annotations
from datetime import date, datetime
from icalendar import Event
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
from pronote_sync.models.homework import Homework
from pronote_sync.models.pronote import PronoteData
from pronote_sync.sync.planner import compute_plan
from pronote_sync.sync.serialization import (
lesson_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,
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 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,
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",
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 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,
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,
)
def _make_vevent(
uid: str,
summary: str,
dtstart: datetime,
dtend: datetime,
status: str = "CONFIRMED",
categories: list[str] | None = None,
) -> Event:
"""Fabrique un VEVENT iCalendar pour les tests.
:param uid: UID de l'événement.
:param summary: Résumé.
:param dtstart: Date/heure de début.
:param dtend: Date/heure de fin.
:param status: Statut.
:param categories: Catégories.
:return: Instance de Event.
:rtype: Event
"""
from icalendar import vDatetime
event = Event()
event.add("UID", uid)
event.add("SUMMARY", summary)
event.add("DTSTART", vDatetime(dtstart))
event.add("DTEND", vDatetime(dtend))
event.add("STATUS", status)
if categories:
event.add("CATEGORIES", categories)
# Ajouter le marqueur de gestion
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
return event
def _make_pronote_data(
lessons: list[Lesson] | None = None,
homeworks: list[Homework] | None = None,
school_events: list[SchoolEvent] | None = None,
) -> PronoteData:
"""Fabrique des données Pronote pour les tests.
:param lessons: Liste des cours.
:param homeworks: Liste des devoirs.
:param school_events: Liste des événements scolaires.
:return: Instance de PronoteData.
:rtype: PronoteData
"""
return PronoteData(
lessons=lessons or [],
homeworks=homeworks or [],
school_events=school_events or [],
messages=[],
target_date=date(2026, 1, 15),
generated_at=datetime(2026, 1, 15, 0, 0),
)
# --- Tests for lessons ---
def test_lesson_add_when_not_in_remote() -> None:
"""Vérifie qu'un cours non présent à distance va dans lessons_to_add.
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234")
pronote_data = _make_pronote_data(lessons=[lesson])
remote_managed: list[tuple[str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 1
assert plan.lessons_to_add[0].id == "L-1234"
assert len(plan.lessons_to_update) == 0
assert len(plan.lessons_to_remove) == 0
def test_lesson_update_when_signature_differs() -> None:
"""Vérifie qu'un cours présent à distance avec une signature différente va dans lessons_to_update.
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
pronote_data = _make_pronote_data(lessons=[lesson])
# Créer un VEVENT distant avec un sujet différent
remote_event = _make_vevent(
uid="L-1234",
summary="Physique", # Différent
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 1
assert plan.lessons_to_update[0].id == "L-1234"
assert len(plan.lessons_to_remove) == 0
def test_lesson_idempotent_when_signature_same() -> None:
"""Vérifie qu'un cours présent à distance avec la même signature n'apparaît dans aucune liste.
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
pronote_data = _make_pronote_data(lessons=[lesson])
# Créer un VEVENT distant avec les mêmes propriétés
remote_event = lesson_to_vevent(lesson)
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
assert len(plan.lessons_to_remove) == 0
def test_lesson_remove_when_not_in_local() -> None:
"""Vérifie qu'un UID distant non présent en local va dans lessons_to_remove.
:return: None
"""
pronote_data = _make_pronote_data(lessons=[])
remote_event = _make_vevent(
uid="L-9999",
summary="Ancien cours",
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [("L-9999", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
assert len(plan.lessons_to_remove) == 1
assert plan.lessons_to_remove[0] == "L-9999"
def test_lesson_cancelled_preserved() -> None:
"""Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour).
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED)
pronote_data = _make_pronote_data(lessons=[lesson])
remote_managed: list[tuple[str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
# Un cours annulé doit aller dans lessons_to_add comme n'importe quel autre cours
assert len(plan.lessons_to_add) == 1
assert plan.lessons_to_add[0].id == "L-1234"
assert plan.lessons_to_add[0].status == LessonStatus.CANCELLED
# --- Tests for homeworks ---
def test_homework_add_when_not_in_remote() -> None:
"""Vérifie qu'un devoir non présent à distance va dans homeworks_to_add.
:return: None
"""
homework = _make_homework(homework_id="HW-5678")
pronote_data = _make_pronote_data(homeworks=[homework])
remote_managed: list[tuple[str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 1
assert plan.homeworks_to_add[0].id == "HW-5678"
assert len(plan.homeworks_to_update) == 0
assert len(plan.homeworks_to_remove) == 0
def test_homework_update_when_signature_differs() -> None:
"""Vérifie qu'un devoir présent à distance avec une signature différente va dans homeworks_to_update.
:return: None
"""
homework = _make_homework(homework_id="HW-5678", subject="Mathématiques")
pronote_data = _make_pronote_data(homeworks=[homework])
# Créer un VEVENT distant avec un sujet différent
remote_event = _make_vevent(
uid="homework-HW-5678",
summary="Devoir: Physique", # Différent
dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 0),
)
remote_managed: list[tuple[str, Event]] = [("homework-HW-5678", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 0
assert len(plan.homeworks_to_update) == 1
assert plan.homeworks_to_update[0].id == "HW-5678"
assert len(plan.homeworks_to_remove) == 0
def test_homework_remove_when_not_in_local() -> None:
"""Vérifie qu'un UID de devoir distant non présent en local va dans homeworks_to_remove.
:return: None
"""
pronote_data = _make_pronote_data(homeworks=[])
remote_event = _make_vevent(
uid="homework-HW-9999",
summary="Devoir: Ancien devoir",
dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 0),
)
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 0
assert len(plan.homeworks_to_update) == 0
assert len(plan.homeworks_to_remove) == 1
assert plan.homeworks_to_remove[0] == "homework-HW-9999"
# --- Tests for school events ---
def test_school_event_add_when_not_in_remote() -> None:
"""Vérifie qu'un événement scolaire non présent à distance va dans school_events_to_add.
:return: None
"""
school_event = _make_school_event(
label="Vacances de Noël",
from_date=date(2026, 12, 20),
)
pronote_data = _make_pronote_data(school_events=[school_event])
remote_managed: list[tuple[str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 1
assert plan.school_events_to_add[0].label == "Vacances de Noël"
assert len(plan.school_events_to_update) == 0
assert len(plan.school_events_to_remove) == 0
def test_school_event_update_when_signature_differs() -> None:
"""Vérifie qu'un événement scolaire présent à distance avec une signature différente
va dans school_events_to_update.
:return: None
"""
school_event = _make_school_event(
label="Vacances de Noël",
from_date=date(2026, 12, 20),
to_date=date(2027, 1, 5),
kind=SchoolEventKind.HOLIDAY,
)
pronote_data = _make_pronote_data(school_events=[school_event])
# Créer un VEVENT distant avec le même UID mais un libellé différent
# L'UID doit correspondre à celui généré par school_event_to_vevent
remote_event = _make_vevent(
uid="school-event-Vacances de Noël-2026-12-20",
summary="Vacances d'hiver", # Différent du local "Vacances de Noël"
dtstart=datetime(2026, 12, 20, 0, 0),
dtend=datetime(2027, 1, 5, 0, 0),
)
remote_managed: list[tuple[str, Event]] = [
("school-event-Vacances de Noël-2026-12-20", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 0
assert len(plan.school_events_to_update) == 1
assert len(plan.school_events_to_remove) == 0
def test_school_event_remove_when_not_in_local() -> None:
"""Vérifie qu'un UID d'événement scolaire distant non présent en local
va dans school_events_to_remove.
:return: None
"""
pronote_data = _make_pronote_data(school_events=[])
remote_event = _make_vevent(
uid="school-event-Ancien événement-2026-01-01",
summary="Ancien événement",
dtstart=datetime(2026, 1, 1, 0, 0),
dtend=datetime(2026, 1, 2, 0, 0),
)
remote_managed: list[tuple[str, Event]] = [
("school-event-Ancien événement-2026-01-01", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 0
assert len(plan.school_events_to_update) == 0
assert len(plan.school_events_to_remove) == 1
assert plan.school_events_to_remove[0] == "school-event-Ancien événement-2026-01-01"
# --- Tests for UID routing ---
def test_uid_routing_homework_to_remove() -> None:
"""Vérifie qu'un UID distant commençant par 'homework-' va dans homeworks_to_remove.
:return: None
"""
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
remote_event = _make_vevent(
uid="homework-HW-9999",
summary="Devoir à supprimer",
dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 0),
)
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
# Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0
assert len(plan.homeworks_to_remove) == 1
assert plan.homeworks_to_remove[0] == "homework-HW-9999"
def test_uid_routing_school_event_to_remove() -> None:
"""Vérifie qu'un UID distant commençant par 'school-event-' va dans school_events_to_remove.
:return: None
"""
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
remote_event = _make_vevent(
uid="school-event-Vacances-2026-12-20",
summary="Événement à supprimer",
dtstart=datetime(2026, 12, 20, 0, 0),
dtend=datetime(2027, 1, 5, 0, 0),
)
remote_managed: list[tuple[str, Event]] = [("school-event-Vacances-2026-12-20", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
# Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0
assert len(plan.school_events_to_remove) == 1
assert plan.school_events_to_remove[0] == "school-event-Vacances-2026-12-20"
# --- Tests for empty inputs ---
def test_empty_inputs_empty_plan() -> None:
"""Vérifie que des entrées vides produisent un plan vide.
:return: None
"""
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
remote_managed: list[tuple[str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
assert len(plan.lessons_to_remove) == 0
assert len(plan.homeworks_to_add) == 0
assert len(plan.homeworks_to_update) == 0
assert len(plan.homeworks_to_remove) == 0
assert len(plan.school_events_to_add) == 0
assert len(plan.school_events_to_update) == 0
assert len(plan.school_events_to_remove) == 0
# --- Tests for mixed scenarios ---
def test_mixed_scenario() -> None:
"""Vérifie un scénario mixte avec ajouts, mises à jour et suppressions.
:return: None
"""
# Données locales
lesson1 = _make_lesson(lesson_id="L-0001") # Nouveau
lesson2 = _make_lesson(lesson_id="L-0002", subject="Mathématiques") # À mettre à jour
homework1 = _make_homework(homework_id="HW-0001") # Nouveau
pronote_data = _make_pronote_data(
lessons=[lesson1, lesson2],
homeworks=[homework1],
school_events=[],
)
# Événements distants
# L-0002 existe mais avec un sujet différent
remote_lesson2 = _make_vevent(
uid="L-0002",
summary="Physique",
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
# L-0003 n'existe plus localement
remote_lesson3 = _make_vevent(
uid="L-0003",
summary="Ancien cours",
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [
("L-0002", remote_lesson2),
("L-0003", remote_lesson3),
]
plan = compute_plan(pronote_data, remote_managed)
# Ajouts
assert len(plan.lessons_to_add) == 1
assert plan.lessons_to_add[0].id == "L-0001"
assert len(plan.homeworks_to_add) == 1
assert plan.homeworks_to_add[0].id == "HW-0001"
# Mises à jour
assert len(plan.lessons_to_update) == 1
assert plan.lessons_to_update[0].id == "L-0002"
# Suppressions
assert len(plan.lessons_to_remove) == 1
assert plan.lessons_to_remove[0] == "L-0003"
def test_unmanaged_events_not_in_remote_managed() -> None:
"""Vérifie que remote_managed ne contient que des événements gérés.
Le contrat indique que remote_managed ne contient déjà que des événements
marqués comme gérés. Le planner ne doit pas filtrer.
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234")
pronote_data = _make_pronote_data(lessons=[lesson])
# remote_managed ne contient que des événements gérés (par hypothèse)
# Donc pas besoin de tester le filtrage ici - c'est la responsabilité de list_managed_events
remote_managed: list[tuple[str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
# Le cours doit être dans lessons_to_add
assert len(plan.lessons_to_add) == 1
# Ensure trailing newline