Files
college-infos/tests/unit/test_caldav_planner.py
Antoine Van Elstraete a1bae41be8 fix(M7): corrections d'audit FIXME_M7 — sécurité, fenêtre, UID, timezone
Corrige les 5 constats de l'audit FIXME_M7 :

#1 (Bloquant) — Protection des événements non marqués :
- upsert_event() vérifie le marqueur X-PRONOTE-SYNC-MANAGED avant
  modification ; lève PronoteSyncError en cas de collision avec un
  événement non géré (aucune écriture)
- delete_event() vérifie le marqueur ; no-op avec warning si non géré
- Méthode privée _is_managed_event() factorisant le contrôle

#2 (Bloquant) — Fenêtre de synchronisation :
- Calcul en journées entières (minuit à minuit exclusif)
- Filtrage des données locales (lessons, homeworks, school_events) avant
  passage au planner
- Paramètre now injectable pour les tests

#3 (Bloquant) — UID canonique vs brut :
- list_managed_events() retourne (raw_uid, canonical_uid, vevent)
- compute_plan() matche par UID canonique, route les raw UID vers
  *_to_remove, retourne le mapping remote_raw_by_canonical
- executor.execute() utilise le raw UID pour les mises à jour (pas de
  doublon)
- Pas de migration destructive des UID distants existants

#4 (Correction) — Normalisation temporelle UTC :
- normalize_datetime_to_utc() dans utils/uid.py : naïve → Europe/Paris →
  UTC ; consciente → UTC
- Utilisée par generate_deterministic_uid() et component_to_signature()
- Deux représentations du même instant → même UID et même signature

#5 (Compatibilité) — date_search déprécié :
- Remplacement par calendar.search(start, end, event=True, expand=True)

Documentation :
- GUIDE_DEV_PYTHON.md : suppression des références obsolètes à
  sync/state.py et état SQLite/JSON ; mise à jour de l'API CalDAV
  (search au lieu de date_search, upsert par UID)
- TODO.md : M7 décoché (corrections en cours de validation)

Tests : 390 passés, couverture 95.61%

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>
2026-09-07 12:24:22 +02:00

654 lines
21 KiB
Python

"""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, str, Event]] = []
plan, raw_mapping = 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, str, Event]] = [("L-1234", "L-1234", remote_event)]
plan, raw_mapping = 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, str, Event]] = [("L-1234", "L-1234", remote_event)]
plan, raw_mapping = 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, str, Event]] = [("L-9999", "L-9999", remote_event)]
plan, raw_mapping = 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_plan_with_suffixed_remote_uid_matches_canonical() -> None:
"""Vérifie qu'un UID distant suffixé apparié par UID canonique ne produit rien.
L'événement distant porte un UID brut suffixé
(``L-1234-20260905T080000Z-Index-Education``) dont la forme canonique
(``L-1234``) correspond au cours local ; les signatures étant identiques,
le plan doit être vide — sans ajout, mise à jour ou suppression artificiels.
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
pronote_data = _make_pronote_data(lessons=[lesson])
# VEVENT distant construit à partir du même cours : contenu sémantique
# identique (seul l'UID brut stocké diffère, capturé par le triplet).
remote_event = lesson_to_vevent(lesson)
remote_managed: list[tuple[str, str, Event]] = [
("L-1234-20260905T080000Z-Index-Education", "L-1234", remote_event)
]
plan, raw_mapping = 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_plan_with_suffixed_remote_uid_and_no_local_adds_to_remove() -> None:
"""Vérifie que la suppression d'un UID distant suffixé utilise l'UID brut.
Un événement distant orphelin (aucun cours local) dont l'UID brut est
suffixé doit être supprimé en ciblant l'UID brut stocké sur le serveur,
et non sa forme canonique.
:return: None
"""
pronote_data = _make_pronote_data(lessons=[])
raw_uid = "L-9999-20260101T080000Z-Index-Education"
remote_event = _make_vevent(
uid=raw_uid,
summary="Ancien cours",
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, str, Event]] = [(raw_uid, "L-9999", remote_event)]
plan, raw_mapping = 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
# La liste de suppression contient l'UID brut, pas la forme canonique.
assert plan.lessons_to_remove[0] == raw_uid
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, str, Event]] = []
plan, raw_mapping = 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, str, Event]] = []
plan, raw_mapping = 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, str, Event]] = [
("homework-HW-5678", "homework-HW-5678", remote_event)
]
plan, raw_mapping = 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, str, Event]] = [
("homework-HW-9999", "homework-HW-9999", remote_event)
]
plan, raw_mapping = 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, str, Event]] = []
plan, raw_mapping = 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, str, Event]] = [
(
"school-event-Vacances de Noël-2026-12-20",
"school-event-Vacances de Noël-2026-12-20",
remote_event,
)
]
plan, raw_mapping = 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, str, Event]] = [
(
"school-event-Ancien événement-2026-01-01",
"school-event-Ancien événement-2026-01-01",
remote_event,
)
]
plan, raw_mapping = 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, str, Event]] = [
("homework-HW-9999", "homework-HW-9999", remote_event)
]
plan, raw_mapping = 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, str, Event]] = [
("school-event-Vacances-2026-12-20", "school-event-Vacances-2026-12-20", remote_event)
]
plan, raw_mapping = 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, str, Event]] = []
plan, raw_mapping = 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, str, Event]] = [
("L-0002", "L-0002", remote_lesson2),
("L-0003", "L-0003", remote_lesson3),
]
plan, raw_mapping = 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, str, Event]] = []
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Le cours doit être dans lessons_to_add
assert len(plan.lessons_to_add) == 1
# Ensure trailing newline