Files
college-infos/tests/unit/test_uid_equivalence.py
Antoine Van Elstraete b4b0247919 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>
2026-09-07 09:24:18 +02:00

190 lines
5.7 KiB
Python

"""Tests unitaires pour l'équivalence des UIDs entre les sources iCal et pronotepy.
Ce module vérifie que les fonctions de normalisation et de génération
d'UIDs produisent les mêmes résultats pour les mêmes données, garantissant
qu'aucun doublon ou suppression artificielle ne se produit lors du
changement de source.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from icalendar import Calendar
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
# --- Test 1: normalize_pronote_uid strips temporal suffix ---
def test_normalize_pronote_uid_strips_temporal_suffix() -> None:
"""Vérifie que normalize_pronote_uid produit un UID canonique stable.
Un UID brut Pronote avec suffixe temporel comme
``L-12345-20260905T120000Z-Index-Education`` doit être normalisé en
``L-12345``.
:return: None
"""
raw_uid = "L-12345-20260905T120000Z-Index-Education"
normalized = normalize_pronote_uid(raw_uid)
assert normalized == "L-12345"
def test_normalize_pronote_uid_strips_education_suffix_only() -> None:
"""Vérifie que normalize_pronote_uid retire aussi le suffixe -Index-Education seul.
:return: None
"""
raw_uid = "L-12345-Index-Education"
normalized = normalize_pronote_uid(raw_uid)
assert normalized == "L-12345"
def test_normalize_pronote_uid_idempotent() -> None:
"""Vérifie que normalize_pronote_uid est idempotente.
:return: None
"""
raw_uid = "L-12345"
normalized = normalize_pronote_uid(raw_uid)
assert normalized == "L-12345"
# --- Test 2: generate_deterministic_uid is stable ---
def test_generate_deterministic_uid_is_stable() -> None:
"""Vérifie que generate_deterministic_uid produit le même UID pour les mêmes entrées.
:return: None
"""
start = datetime(2026, 9, 5, 8, 0, 0)
end = datetime(2026, 9, 5, 9, 0, 0)
subject = "Mathématiques"
teachers = ["M. Dupont"]
rooms = ["204"]
group = "Classe entière"
uid1 = generate_deterministic_uid(
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
)
uid2 = generate_deterministic_uid(
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
)
assert uid1 == uid2
assert len(uid1) == 12
assert all(c in "0123456789abcdef" for c in uid1)
def test_generate_deterministic_uid_ignores_order() -> None:
"""Vérifie que l'ordre des enseignants/salles n'affecte pas le résultat.
:return: None
"""
start = datetime(2026, 9, 5, 8, 0, 0)
end = datetime(2026, 9, 5, 9, 0, 0)
subject = "Mathématiques"
uid1 = generate_deterministic_uid(
start=start,
end=end,
subject=subject,
teachers=["M. Dupont", "Mme Martin"],
rooms=["204", "205"],
group=None,
)
uid2 = generate_deterministic_uid(
start=start,
end=end,
subject=subject,
teachers=["Mme Martin", "M. Dupont"],
rooms=["205", "204"],
group=None,
)
assert uid1 == uid2
# --- Test 3: iCal and pronotepy produce same UID with raw UID ---
def test_ical_and_pronotepy_produce_same_uid_with_raw_uid() -> None:
"""Vérifie que iCal et pronotepy produisent le même UID avec un UID brut.
Comme les deux sources utilisent normalize_pronote_uid, le résultat doit
être identique.
:return: None
"""
raw_uid = "Edt_12345@index-education.net-20260905T120000Z-Index-Education"
# Simulation du chemin iCal
ical_normalized = normalize_pronote_uid(raw_uid)
# Simulation du chemin pronotepy
pronotepy_normalized = normalize_pronote_uid(raw_uid)
assert ical_normalized == pronotepy_normalized
assert ical_normalized == "Edt_12345@index-education.net"
# --- Test 4: iCal and pronotepy produce same UID with fallback ---
def test_ical_and_pronotepy_produce_same_uid_with_fallback() -> None:
"""Vérifie que iCal et pronotepy produisent le même UID déterministe en l'absence d'UID brut.
:return: None
"""
start = datetime(2026, 9, 5, 8, 0, 0)
end = datetime(2026, 9, 5, 9, 0, 0)
subject = "Mathématiques"
teachers = ["M. Dupont"]
rooms = ["204"]
group = "Classe entière"
# Simulation du chemin iCal (parse_ical utilise generate_deterministic_uid)
ical_uid = generate_deterministic_uid(
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
)
# Simulation du chemin pronotepy (get_lessons utilise generate_deterministic_uid)
pronotepy_uid = generate_deterministic_uid(
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
)
assert ical_uid == pronotepy_uid
# --- Test 5: UID from iCal fixture is normalized ---
def test_uid_from_ical_fixture_is_normalized() -> None:
"""Vérifie que les UIDs extraits du fixture pronote-4e.ics sont normalisés correctement.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
with open(fixture_path, "rb") as f:
cal = Calendar.from_ical(f.read())
normalized_uids = []
for component in cal.walk("VEVENT"):
raw_uid = str(component.get("UID"))
normalized = normalize_pronote_uid(raw_uid)
normalized_uids.append(normalized)
# Vérifie que tous les UIDs sont normalisés (plus courts que les UIDs bruts)
assert len(normalized_uids) >= 2
# Vérifie que les UIDs normalisés ne contiennent pas de suffixes temporels
for uid in normalized_uids:
assert "-Index-Education" not in uid
assert "-2026" not in uid # Pas de suffixe temporel
# Vérifie que le premier UID est bien normalisé
assert "Edt_12345@index-education.net" in normalized_uids