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>
190 lines
5.7 KiB
Python
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
|