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>
683 lines
22 KiB
Python
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
|