"""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