Files
college-infos/tests/unit/test_caldav_executor.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

683 lines
22 KiB
Python

"""Tests unitaires pour l'exécuteur de synchronisation CalDAV.
Ce module vérifie que :class:`CalDAVSyncExecutor` applique correctement
un plan de synchronisation contre une passerelle CalDAV mockée, avec
la bonne gestion du mode dry-run, des compteurs et des erreurs.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from pronote_sync.errors import PronoteSyncError
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
from pronote_sync.models.homework import Homework
from pronote_sync.models.sync import CalDAVSyncPlan, CalDAVSyncStatus
from pronote_sync.sync.executor import CalDAVSyncExecutor
if TYPE_CHECKING:
pass
# --- 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,
)
# --- Fixtures ---
@pytest.fixture
def mock_gateway() -> MagicMock:
"""Fournit une passerelle CalDAV mockée.
:return: MagicMock configuré comme une CalDAVGateway.
:rtype: MagicMock
"""
gateway = MagicMock()
gateway.upsert_event = MagicMock()
gateway.delete_event = MagicMock()
return gateway
@pytest.fixture
def empty_plan() -> CalDAVSyncPlan:
"""Fournit un plan de synchronisation vide.
:return: CalDAVSyncPlan vide.
:rtype: CalDAVSyncPlan
"""
return CalDAVSyncPlan()
@pytest.fixture
def plan_with_lesson_add() -> CalDAVSyncPlan:
"""Fournit un plan avec un cours à ajouter.
:return: CalDAVSyncPlan avec un cours à ajouter.
:rtype: CalDAVSyncPlan
"""
lesson = _make_lesson(lesson_id="L-1234")
return CalDAVSyncPlan(lessons_to_add=[lesson])
@pytest.fixture
def plan_with_lesson_update() -> CalDAVSyncPlan:
"""Fournit un plan avec un cours à mettre à jour.
:return: CalDAVSyncPlan avec un cours à mettre à jour.
:rtype: CalDAVSyncPlan
"""
lesson = _make_lesson(lesson_id="L-1234")
return CalDAVSyncPlan(lessons_to_update=[lesson])
@pytest.fixture
def plan_with_lesson_remove() -> CalDAVSyncPlan:
"""Fournit un plan avec un cours à supprimer.
:return: CalDAVSyncPlan avec un cours à supprimer.
:rtype: CalDAVSyncPlan
"""
return CalDAVSyncPlan(lessons_to_remove=["L-1234"])
@pytest.fixture
def plan_with_homework_add() -> CalDAVSyncPlan:
"""Fournit un plan avec un devoir à ajouter.
:return: CalDAVSyncPlan avec un devoir à ajouter.
:rtype: CalDAVSyncPlan
"""
homework = _make_homework(homework_id="HW-5678")
return CalDAVSyncPlan(homeworks_to_add=[homework])
@pytest.fixture
def plan_with_homework_remove() -> CalDAVSyncPlan:
"""Fournit un plan avec un devoir à supprimer.
:return: CalDAVSyncPlan avec un devoir à supprimer.
:rtype: CalDAVSyncPlan
"""
return CalDAVSyncPlan(homeworks_to_remove=["homework-HW-5678"])
@pytest.fixture
def plan_with_school_event_add() -> CalDAVSyncPlan:
"""Fournit un plan avec un événement scolaire à ajouter.
:return: CalDAVSyncPlan avec un événement scolaire à ajouter.
:rtype: CalDAVSyncPlan
"""
school_event = _make_school_event()
return CalDAVSyncPlan(school_events_to_add=[school_event])
@pytest.fixture
def plan_with_school_event_remove() -> CalDAVSyncPlan:
"""Fournit un plan avec un événement scolaire à supprimer.
:return: CalDAVSyncPlan avec un événement scolaire à supprimer.
:rtype: CalDAVSyncPlan
"""
return CalDAVSyncPlan(school_events_to_remove=["school-event-Vacances-2026-12-20"])
# --- Dry-run tests ---
def test_dry_run_add_does_not_call_gateway(
mock_gateway: MagicMock, plan_with_lesson_add: CalDAVSyncPlan
) -> None:
"""Vérifie que dry_run=True n'appelle pas gateway.upsert_event pour un ajout.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_add: Plan avec un cours à ajouter.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
result = executor.execute(plan_with_lesson_add)
mock_gateway.upsert_event.assert_not_called()
assert result.added == 1
assert result.updated == 0
assert result.removed == 0
def test_dry_run_update_does_not_call_gateway(
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
) -> None:
"""Vérifie que dry_run=True n'appelle pas gateway.upsert_event pour une mise à jour.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
result = executor.execute(plan_with_lesson_update)
mock_gateway.upsert_event.assert_not_called()
assert result.added == 0
assert result.updated == 1
assert result.removed == 0
def test_dry_run_delete_does_not_call_gateway(
mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan
) -> None:
"""Vérifie que dry_run=True n'appelle pas gateway.delete_event.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_remove: Plan avec un cours à supprimer.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
result = executor.execute(plan_with_lesson_remove)
mock_gateway.delete_event.assert_not_called()
assert result.added == 0
assert result.updated == 0
assert result.removed == 1
def test_dry_run_all_operations(
mock_gateway: MagicMock,
) -> None:
"""Vérifie que dry_run=True fonctionne pour toutes les catégories.
:param mock_gateway: Passerelle mockée.
:return: None
"""
plan = CalDAVSyncPlan(
lessons_to_add=[_make_lesson(lesson_id="L-0001")],
lessons_to_update=[_make_lesson(lesson_id="L-0002")],
lessons_to_remove=["L-0003"],
homeworks_to_add=[_make_homework(homework_id="HW-0001")],
homeworks_to_update=[_make_homework(homework_id="HW-0002")],
homeworks_to_remove=["homework-HW-0003"],
school_events_to_add=[_make_school_event()],
school_events_to_update=[_make_school_event(label="Événement 2")],
school_events_to_remove=["school-event-Événement-2026-01-01"],
)
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
result = executor.execute(plan)
mock_gateway.upsert_event.assert_not_called()
mock_gateway.delete_event.assert_not_called()
assert result.added == 3 # 1 lesson + 1 homework + 1 school event
assert result.updated == 3 # 1 lesson + 1 homework + 1 school event
assert result.removed == 3 # 1 lesson + 1 homework + 1 school event
# --- Real execution tests ---
def test_real_add_calls_gateway(
mock_gateway: MagicMock, plan_with_lesson_add: CalDAVSyncPlan
) -> None:
"""Vérifie que dry_run=False appelle gateway.upsert_event pour un ajout.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_add: Plan avec un cours à ajouter.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_lesson_add)
mock_gateway.upsert_event.assert_called_once()
# Vérifier que les arguments sont (texte iCalendar, uid)
call_args = mock_gateway.upsert_event.call_args
assert isinstance(call_args[0][0], str)
assert "BEGIN:VEVENT" in call_args[0][0]
assert call_args[0][1] == "L-1234"
assert result.added == 1
def test_real_update_calls_gateway(
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
) -> None:
"""Vérifie que dry_run=False appelle gateway.upsert_event pour une mise à jour.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(
plan_with_lesson_update,
remote_raw_by_canonical={"L-1234": "L-1234"},
)
mock_gateway.upsert_event.assert_called_once()
assert result.updated == 1
def test_update_uses_raw_uid_from_mapping(
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
) -> None:
"""Vérifie que la mise à jour cible l'UID brut distant du mapping.
Pour une mise à jour, l'exécuteur doit appeler ``upsert_event`` avec
l'UID brut fourni par ``remote_raw_by_canonical`` (celui stocké sur le
serveur) et non l'UID canonique, afin d'éviter la création d'un doublon.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
:return: None
"""
raw_uid = "L-1234-20260905T080000Z-Index-Education"
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(
plan_with_lesson_update,
remote_raw_by_canonical={"L-1234": raw_uid},
)
call_args = mock_gateway.upsert_event.call_args
assert call_args[0][0] is not None
# Le contenu VEVENT porte l'UID canonique du modèle.
assert "UID:L-1234" in call_args[0][0]
# La cible (2e argument) est l'UID brut distant, pas l'UID canonique.
assert call_args[0][1] == raw_uid
assert result.updated == 1
assert len(result.errors) == 0
def test_update_without_mapping_raises_error(
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
) -> None:
"""Vérifie qu'une mise à jour sans mapping distant consigne une erreur.
Si ``remote_raw_by_canonical`` est absent (ou sans clé pour l'UID
canonique), l'exécuteur ne doit pas retomber silencieusement sur l'UID
canonique (créant un doublon) : l'erreur est consignée dans
``result.errors`` et le lot continue sans appeler la passerelle.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_lesson_update)
mock_gateway.upsert_event.assert_not_called()
assert len(result.errors) == 1
assert "UID canonique sans correspondant distant" in result.errors[0]
assert result.updated == 0
assert result.status == CalDAVSyncStatus.FAILED
def test_real_delete_calls_gateway(
mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan
) -> None:
"""Vérifie que dry_run=False appelle gateway.delete_event.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_remove: Plan avec un cours à supprimer.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_lesson_remove)
mock_gateway.delete_event.assert_called_once_with("L-1234")
assert result.removed == 1
def test_real_execution_all_operations(
mock_gateway: MagicMock,
) -> None:
"""Vérifie que dry_run=False appelle la passerelle pour toutes les opérations.
:param mock_gateway: Passerelle mockée.
:return: None
"""
plan = CalDAVSyncPlan(
lessons_to_add=[_make_lesson(lesson_id="L-0001")],
lessons_to_update=[_make_lesson(lesson_id="L-0002")],
lessons_to_remove=["L-0003"],
homeworks_to_add=[_make_homework(homework_id="HW-0001")],
homeworks_to_remove=["homework-HW-0002"],
school_events_to_add=[_make_school_event()],
school_events_to_remove=["school-event-Vacances-2026-12-20"],
)
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(
plan,
remote_raw_by_canonical={"L-0002": "L-0002"},
)
# upsert_event appelé pour les ajouts et mises à jour
# 1 lesson add + 1 lesson update + 1 homework add + 1 school event add = 4
assert mock_gateway.upsert_event.call_count == 4
# delete_event appelé pour les suppressions
assert mock_gateway.delete_event.call_count == 3 # 1 lesson + 1 homework + 1 school event
assert result.added == 3 # 1 lesson + 1 homework + 1 school event
assert result.updated == 1 # 1 lesson
assert result.removed == 3 # 1 lesson + 1 homework + 1 school event
# --- Error handling tests ---
def test_error_isolation_save_failure(
mock_gateway: MagicMock,
) -> None:
"""Vérifie qu'une erreur sur upsert_event est capturée et le batch continue.
:param mock_gateway: Passerelle mockée.
:return: None
"""
lesson1 = _make_lesson(lesson_id="L-0001")
lesson2 = _make_lesson(lesson_id="L-0002")
# Configurer le mock pour lever une erreur sur le premier appel
mock_gateway.upsert_event.side_effect = [
PronoteSyncError("Échec de l'écriture"),
None, # Le deuxième appel réussit
]
plan = CalDAVSyncPlan(lessons_to_add=[lesson1, lesson2])
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan)
# Les deux appels ont été tentés
assert mock_gateway.upsert_event.call_count == 2
# Une erreur a été capturée
assert len(result.errors) == 1
assert "Échec de l'écriture" in result.errors[0]
# Le compteur d'ajouts est à 1 (seul le deuxième a réussi)
assert result.added == 1
# Le statut est FAILED car il y a des erreurs
assert result.status == CalDAVSyncStatus.FAILED
def test_error_isolation_delete_failure(
mock_gateway: MagicMock,
) -> None:
"""Vérifie qu'une erreur sur delete_event est capturée et le batch continue.
:param mock_gateway: Passerelle mockée.
:return: None
"""
mock_gateway.delete_event.side_effect = PronoteSyncError("Échec de la suppression")
plan = CalDAVSyncPlan(
lessons_to_remove=["L-0001", "L-0002"],
)
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan)
# Les deux appels ont été tentés
assert mock_gateway.delete_event.call_count == 2
# Deux erreurs ont été capturées
assert len(result.errors) == 2
assert all("Échec de la suppression" in err for err in result.errors)
# Aucun compteur de suppression n'a été incrémenté
assert result.removed == 0
# Le statut est FAILED
assert result.status == CalDAVSyncStatus.FAILED
def test_error_isolation_mixed_operations(
mock_gateway: MagicMock,
) -> None:
"""Vérifie que les erreurs sont isolées entre différentes opérations.
:param mock_gateway: Passerelle mockée.
:return: None
"""
lesson = _make_lesson(lesson_id="L-0001")
# upsert_event échoue, delete_event réussit
mock_gateway.upsert_event.side_effect = PronoteSyncError("Échec save")
mock_gateway.delete_event.return_value = None
plan = CalDAVSyncPlan(
lessons_to_add=[lesson],
lessons_to_remove=["L-0002"],
)
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan)
assert mock_gateway.upsert_event.call_count == 1
assert mock_gateway.delete_event.call_count == 1
assert len(result.errors) == 1
assert "Échec save" in result.errors[0]
assert result.added == 0
assert result.removed == 1
assert result.status == CalDAVSyncStatus.FAILED
# --- Status tests ---
def test_status_skipped_when_no_operations(
mock_gateway: MagicMock, empty_plan: CalDAVSyncPlan
) -> None:
"""Vérifie que le statut est SKIPPED quand aucune opération n'est à effectuer.
:param mock_gateway: Passerelle mockée.
:param empty_plan: Plan vide.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(empty_plan)
assert result.status == CalDAVSyncStatus.SKIPPED
assert result.added == 0
assert result.updated == 0
assert result.removed == 0
def test_status_success_when_no_errors(
mock_gateway: MagicMock, plan_with_lesson_add: CalDAVSyncPlan
) -> None:
"""Vérifie que le statut est SUCCESS quand les opérations réussissent sans erreur.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_add: Plan avec un cours à ajouter.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_lesson_add)
assert result.status == CalDAVSyncStatus.SUCCESS
def test_status_failed_when_errors_present(
mock_gateway: MagicMock,
) -> None:
"""Vérifie que le statut est FAILED quand des erreurs sont présentes.
:param mock_gateway: Passerelle mockée.
:return: None
"""
lesson = _make_lesson(lesson_id="L-0001")
mock_gateway.upsert_event.side_effect = PronoteSyncError("Échec")
plan = CalDAVSyncPlan(lessons_to_add=[lesson])
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan)
assert result.status == CalDAVSyncStatus.FAILED
# --- Homework and School Event tests ---
def test_homework_add_real_execution(
mock_gateway: MagicMock, plan_with_homework_add: CalDAVSyncPlan
) -> None:
"""Vérifie l'exécution réelle pour l'ajout d'un devoir.
:param mock_gateway: Passerelle mockée.
:param plan_with_homework_add: Plan avec un devoir à ajouter.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_homework_add)
mock_gateway.upsert_event.assert_called_once()
call_args = mock_gateway.upsert_event.call_args
vcalendar_text = call_args[0][0]
assert "homework-HW-5678" in vcalendar_text
assert call_args[0][1] == "homework-HW-5678"
assert "Devoir: Mathématiques" in vcalendar_text
assert result.added == 1
def test_homework_delete_real_execution(
mock_gateway: MagicMock, plan_with_homework_remove: CalDAVSyncPlan
) -> None:
"""Vérifie l'exécution réelle pour la suppression d'un devoir.
:param mock_gateway: Passerelle mockée.
:param plan_with_homework_remove: Plan avec un devoir à supprimer.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_homework_remove)
mock_gateway.delete_event.assert_called_once_with("homework-HW-5678")
assert result.removed == 1
def test_school_event_add_real_execution(
mock_gateway: MagicMock, plan_with_school_event_add: CalDAVSyncPlan
) -> None:
"""Vérifie l'exécution réelle pour l'ajout d'un événement scolaire.
:param mock_gateway: Passerelle mockée.
:param plan_with_school_event_add: Plan avec un événement scolaire à ajouter.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_school_event_add)
mock_gateway.upsert_event.assert_called_once()
call_args = mock_gateway.upsert_event.call_args
vcalendar_text = call_args[0][0]
assert "school-event-Vacances de Noël-2026-12-20" in vcalendar_text
assert call_args[0][1] == "school-event-Vacances de Noël-2026-12-20"
assert result.added == 1
def test_school_event_delete_real_execution(
mock_gateway: MagicMock, plan_with_school_event_remove: CalDAVSyncPlan
) -> None:
"""Vérifie l'exécution réelle pour la suppression d'un événement scolaire.
:param mock_gateway: Passerelle mockée.
:param plan_with_school_event_remove: Plan avec un événement scolaire à supprimer.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_school_event_remove)
mock_gateway.delete_event.assert_called_once_with("school-event-Vacances-2026-12-20")
assert result.removed == 1
# Ensure trailing newline