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>
This commit is contained in:
@@ -85,13 +85,50 @@ class FakeCalendar:
|
||||
self._server = server
|
||||
self.url = "https://caldav.example.com/remote.php/dav/calendars/test-user/pronote-sync/"
|
||||
|
||||
def date_search(
|
||||
self, start: datetime, end: datetime, expand: bool = True
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
event: bool = True,
|
||||
expand: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> list[FakeCalendarEvent]:
|
||||
"""Retourne tous les événements comme de faux objets calendrier."""
|
||||
"""Retourne les événements dont la période chevauche la fenêtre donnée.
|
||||
|
||||
Reproduit le comportement d'un vrai serveur CalDAV : seuls les
|
||||
événements dont la période chevauche la fenêtre ``[start, end)`` sont
|
||||
retournés, comme de faux objets calendrier.
|
||||
|
||||
:param start: Début de la fenêtre de recherche (``None`` : pas de
|
||||
borne inférieure).
|
||||
:param end: Fin de la fenêtre de recherche (``None`` : pas de borne
|
||||
supérieure).
|
||||
:param event: Non utilisé (le faux ne traite que des VEVENT).
|
||||
:param expand: Non utilisé (compatibilité avec la passerelle).
|
||||
:param kwargs: Paramètres additionnels ignorés (compatibilité avec
|
||||
l'API ``caldav``).
|
||||
:return: Liste des événements chevauchant la fenêtre.
|
||||
:rtype: list[FakeCalendarEvent]
|
||||
"""
|
||||
results: list[FakeCalendarEvent] = []
|
||||
for uid, ical_text in self._server._events.items():
|
||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk("VEVENT"):
|
||||
raw_start = component.get("dtstart")
|
||||
raw_end = component.get("dtend")
|
||||
if raw_start is None:
|
||||
continue
|
||||
event_start = raw_start.dt
|
||||
event_end = raw_end.dt if raw_end is not None else event_start
|
||||
overlaps = True
|
||||
if start is not None:
|
||||
overlaps = overlaps and event_end > start
|
||||
if end is not None:
|
||||
overlaps = overlaps and event_start < end
|
||||
if overlaps:
|
||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||
break
|
||||
return results
|
||||
|
||||
def get_event_by_uid(self, uid: str) -> FakeCalendarEvent:
|
||||
@@ -266,6 +303,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -308,6 +346,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result1.added == 1
|
||||
|
||||
@@ -316,6 +355,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result2.status == CalDAVSyncStatus.SKIPPED
|
||||
assert result2.added == 0
|
||||
@@ -366,6 +406,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications : le résultat indique un ajout, mais le serveur reste vide
|
||||
@@ -415,6 +456,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -454,6 +496,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -496,6 +539,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -538,6 +582,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications : l'événement non géré est toujours présent
|
||||
@@ -593,6 +638,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -632,6 +678,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -667,6 +714,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 12, 20, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -758,6 +806,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -770,3 +819,111 @@ class TestCalDAVSynchronize:
|
||||
events = fake_caldav_server.get_events()
|
||||
assert "UNMANAGED-001" in events
|
||||
assert len(events) == 4 # L-001 (mis à jour), L-002, hw-002, UNMANAGED-001
|
||||
|
||||
def test_sync_filters_out_of_window_events(
|
||||
self,
|
||||
fake_caldav_server: FakeCalDAVServer,
|
||||
full_settings: Settings,
|
||||
) -> None:
|
||||
"""Teste qu'un cours hors fenêtre n'est pas écrit sur le calendrier.
|
||||
|
||||
Avec ``now=2026-01-14`` et la configuration par défaut (7 jours dans
|
||||
le passé, 30 dans le futur), la fenêtre couvre les journées complètes
|
||||
du 2026-01-07 au 2026-02-14 (fin exclusive) : un cours le 2026-02-15
|
||||
doit être filtré et ne jamais atteindre le planificateur ni le
|
||||
calendrier.
|
||||
"""
|
||||
# Données Pronote avec un cours hors de la fenêtre
|
||||
pronote_data = PronoteData(
|
||||
lessons=[
|
||||
Lesson(
|
||||
id="L-001",
|
||||
start=datetime(2026, 2, 15, 8, 0),
|
||||
end=datetime(2026, 2, 15, 9, 0),
|
||||
subject="Mathématiques",
|
||||
teachers=("Prof Dupont",),
|
||||
rooms=("Salle 101",),
|
||||
status=LessonStatus.NORMAL,
|
||||
group=None,
|
||||
content=None,
|
||||
)
|
||||
],
|
||||
homeworks=[],
|
||||
school_events=[],
|
||||
messages=[],
|
||||
target_date=datetime(2026, 2, 15).date(),
|
||||
generated_at=datetime(2026, 2, 15, 0, 0),
|
||||
)
|
||||
|
||||
# Synchronisation
|
||||
result = synchronize(
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications : le cours hors fenêtre n'est jamais écrit
|
||||
assert result.status == CalDAVSyncStatus.SKIPPED
|
||||
assert result.added == 0
|
||||
assert result.updated == 0
|
||||
assert result.removed == 0
|
||||
assert len(fake_caldav_server.get_events()) == 0
|
||||
|
||||
def test_sync_idempotent_at_window_edge(
|
||||
self,
|
||||
fake_caldav_server: FakeCalDAVServer,
|
||||
full_settings: Settings,
|
||||
) -> None:
|
||||
"""Teste l'idempotence pour un cours tardif en fin de fenêtre.
|
||||
|
||||
Un cours à 23:00 le dernier jour complet de la fenêtre (2026-02-13 ;
|
||||
la fenêtre se termine de façon exclusive le 2026-02-14) doit être
|
||||
ajouté une seule fois : une seconde exécution identique doit le
|
||||
détecter comme déjà présent et ne rien réécrire.
|
||||
"""
|
||||
# Données Pronote avec un cours tardif le dernier jour de la fenêtre
|
||||
pronote_data = PronoteData(
|
||||
lessons=[
|
||||
Lesson(
|
||||
id="L-001",
|
||||
start=datetime(2026, 2, 13, 23, 0),
|
||||
end=datetime(2026, 2, 13, 23, 30),
|
||||
subject="Mathématiques",
|
||||
teachers=("Prof Dupont",),
|
||||
rooms=("Salle 101",),
|
||||
status=LessonStatus.NORMAL,
|
||||
group=None,
|
||||
content=None,
|
||||
)
|
||||
],
|
||||
homeworks=[],
|
||||
school_events=[],
|
||||
messages=[],
|
||||
target_date=datetime(2026, 2, 13).date(),
|
||||
generated_at=datetime(2026, 2, 13, 0, 0),
|
||||
)
|
||||
|
||||
# Première synchronisation : le cours est ajouté
|
||||
result1 = synchronize(
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result1.status == CalDAVSyncStatus.SUCCESS
|
||||
assert result1.added == 1
|
||||
assert len(fake_caldav_server.get_events()) == 1
|
||||
|
||||
# Seconde synchronisation identique : rien n'est réécrit
|
||||
result2 = synchronize(
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result2.status == CalDAVSyncStatus.SKIPPED
|
||||
assert result2.added == 0
|
||||
assert result2.updated == 0
|
||||
assert result2.removed == 0
|
||||
assert len(fake_caldav_server.get_events()) == 1
|
||||
|
||||
Reference in New Issue
Block a user