"""Tests d'intégration pour la synchronisation CalDAV. Ce module teste le flux complet de synchronisation avec un faux serveur CalDAV en mémoire, en vérifiant l'idempotence, le mode dry-run, les ajouts/mises à jour/suppressions, et la préservation des événements non gérés. """ from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING, Any import pytest from caldav.lib.error import NotFoundError from icalendar import Calendar, Event from pydantic import SecretStr from pronote_sync.config.settings import AppSettings, CalDAVSettings, Settings from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind from pronote_sync.models.homework import Homework from pronote_sync.models.pronote import PronoteData from pronote_sync.models.sync import CalDAVSyncStatus from pronote_sync.sync.synchronizer import synchronize if TYPE_CHECKING: pass class FakeCalendarEvent: """Faux événement calendrier pour les tests. Reproduit le contrat minimal de ``CalendarObjectResource`` utilisé par la passerelle : contenu brut accessible via ``data`` (lecture/écriture), enregistrement via ``save()``, composant iCalendar et suppression. """ def __init__( self, ical_text: str, uid: str | None = None, server: FakeCalDAVServer | None = None ): self._ical_text = ical_text self._uid = uid self._server = server @property def data(self) -> str: """Retourne le contenu iCalendar brut de l'événement. :return: Texte iCalendar de l'événement. :rtype: str """ return self._ical_text @data.setter def data(self, value: str) -> None: """Remplace le contenu iCalendar brut de l'événement. :param value: Nouveau texte iCalendar de l'événement. """ self._ical_text = value def save(self) -> None: """Enregistre le contenu courant de l'événement sur le serveur.""" if self._server and self._uid: self._server._events[self._uid] = self._ical_text @property def icalendar_component(self) -> Calendar: """Retourne le composant iCalendar de l'événement. :return: Composant iCalendar parsé. :rtype: Calendar """ return Calendar.from_ical(self._ical_text) def delete(self) -> None: """Supprime l'événement du serveur.""" if self._server and self._uid: self._server._events.pop(self._uid, None) class FakeCalendar: """Faux calendrier CalDAV pour les tests.""" def __init__(self, server: FakeCalDAVServer): self._server = server self.url = "https://caldav.example.com/remote.php/dav/calendars/test-user/pronote-sync/" def search( self, *, start: datetime | None = None, end: datetime | None = None, event: bool = True, expand: bool = True, **kwargs: Any, ) -> list[FakeCalendarEvent]: """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(): 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: """Retourne un événement par son UID, ou lève NotFoundError s'il est absent.""" if uid in self._server._events: return FakeCalendarEvent(self._server._events[uid], uid=uid, server=self._server) raise NotFoundError(f"Event {uid} not found") def add_event(self, *, ical: str) -> None: """Ajoute un événement en extrayant l'UID du texte iCalendar.""" cal = Calendar.from_ical(ical) for component in cal.walk("VEVENT"): uid = str(component.get("UID")) self._server._events[uid] = ical @property def icalendar_component(self) -> Calendar: """Propriété non utilisée pour le calendrier lui-même.""" raise NotImplementedError class FakePrincipal: """Faux principal CalDAV pour les tests.""" def __init__(self, server: FakeCalDAVServer): self._server = server def calendars(self) -> list[FakeCalendar]: """Retourne les calendriers du principal. :return: Liste contenant le faux calendrier cible. :rtype: list[FakeCalendar] """ return [FakeCalendar(self._server)] class FakeDAVClient: """Faux client DAV pour les tests.""" def __init__(self, server: FakeCalDAVServer): self._server = server def principal(self) -> FakePrincipal: """Retourne un faux principal CalDAV pour la découverte.""" return FakePrincipal(self._server) class FakeCalDAVServer: """Faux serveur CalDAV en mémoire pour les tests d'intégration.""" def __init__(self) -> None: self._events: dict[str, str] = {} # uid -> ical text def client_factory(self, **kwargs: Any) -> FakeDAVClient: """Retourne un faux client DAV.""" return FakeDAVClient(self) def get_events(self) -> dict[str, str]: """Retourne tous les événements stockés.""" return self._events.copy() def clear(self) -> None: """Efface tous les événements.""" self._events.clear() @pytest.fixture def fake_caldav_server() -> FakeCalDAVServer: """Fournit un faux serveur CalDAV vide pour les tests.""" return FakeCalDAVServer() @pytest.fixture def caldav_settings() -> CalDAVSettings: """Configuration CalDAV de test.""" return CalDAVSettings( url=SecretStr("https://caldav.example.com/remote.php/dav/"), username="test-user", password=SecretStr("test-password"), calendar_path="/pronote-sync/", ) @pytest.fixture def app_settings() -> AppSettings: """Paramètres d'application de test.""" return AppSettings( dry_run=False, log_level="INFO", sync_past_days=7, sync_future_days=30, ) @pytest.fixture def full_settings(caldav_settings: CalDAVSettings, app_settings: AppSettings) -> Settings: """Configuration complète de test.""" return Settings( caldav=caldav_settings, app=app_settings, ) def _create_vevent_text( uid: str, summary: str, start: datetime, end: datetime, status: str = "CONFIRMED", managed: bool = True, ) -> str: """Crée un texte iCalendar pour un VEVENT. :param uid: Identifiant unique de l'événement. :param summary: Résumé de l'événement. :param start: Date/heure de début. :param end: Date/heure de fin. :param status: Statut de l'événement. :param managed: Si True, ajoute le marqueur de gestion. :return: Texte iCalendar de l'événement. :rtype: str """ cal = Calendar() cal.add("prodid", "-//pronote-sync//test//FR") cal.add("version", "2.0") event = Event() event.add("uid", uid) event.add("summary", summary) event.add("dtstart", start) event.add("dtend", end) event.add("status", status) if managed: event.add("X-PRONOTE-SYNC-MANAGED", "v1") cal.add_component(event) return cal.to_ical().decode("utf-8") # type: ignore[no-any-return] class TestCalDAVSynchronize: """Tests d'intégration pour la synchronisation CalDAV.""" def test_full_sync_add( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste l'ajout d'un cours lors d'une synchronisation complète.""" # Données Pronote avec un cours pronote_data = PronoteData( lessons=[ Lesson( id="L-001", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 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, 1, 15).date(), generated_at=datetime(2026, 1, 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 assert result.status == CalDAVSyncStatus.SUCCESS assert result.added == 1 assert result.updated == 0 assert result.removed == 0 assert len(fake_caldav_server.get_events()) == 1 def test_full_sync_idempotent( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste l'idempotence : deux synchronisations identiques.""" # Données Pronote avec un cours pronote_data = PronoteData( lessons=[ Lesson( id="L-001", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 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, 1, 15).date(), generated_at=datetime(2026, 1, 15, 0, 0), ) # Première synchronisation 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.added == 1 # Deuxième synchronisation avec les mêmes données 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 def test_full_sync_dry_run( self, fake_caldav_server: FakeCalDAVServer, caldav_settings: CalDAVSettings, ) -> None: """Teste le mode dry-run : aucune écriture sur le serveur.""" app_settings = AppSettings( dry_run=True, log_level="INFO", sync_past_days=7, sync_future_days=30, ) full_settings = Settings( caldav=caldav_settings, app=app_settings, ) # Données Pronote avec un cours pronote_data = PronoteData( lessons=[ Lesson( id="L-001", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 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, 1, 15).date(), generated_at=datetime(2026, 1, 15, 0, 0), ) # Synchronisation en mode dry-run 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 résultat indique un ajout, mais le serveur reste vide assert result.added == 1 assert len(fake_caldav_server.get_events()) == 0 def test_full_sync_update( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste la mise à jour d'un cours existant.""" # Pré-remplir le serveur avec un événement existant existing_event = _create_vevent_text( uid="L-001", summary="Mathématiques", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 15, 9, 0), managed=True, ) fake_caldav_server._events["L-001"] = existing_event # Données Pronote avec le même cours mais avec un sujet modifié pronote_data = PronoteData( lessons=[ Lesson( id="L-001", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 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, 1, 15).date(), generated_at=datetime(2026, 1, 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 assert result.status == CalDAVSyncStatus.SUCCESS assert result.added == 0 assert result.updated == 1 assert result.removed == 0 def test_full_sync_remove( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste la suppression d'un événement distant non présent localement.""" # Pré-remplir le serveur avec un événement existant existing_event = _create_vevent_text( uid="L-001", summary="Mathématiques", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 15, 9, 0), managed=True, ) fake_caldav_server._events["L-001"] = existing_event # Données Pronote sans ce cours pronote_data = PronoteData( lessons=[], homeworks=[], school_events=[], messages=[], target_date=datetime(2026, 1, 15).date(), generated_at=datetime(2026, 1, 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 assert result.status == CalDAVSyncStatus.SUCCESS assert result.added == 0 assert result.updated == 0 assert result.removed == 1 assert len(fake_caldav_server.get_events()) == 0 def test_cancelled_lesson_preserved( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste qu'un cours annulé est synchronisé avec le statut CANCELLED.""" # Données Pronote avec un cours annulé pronote_data = PronoteData( lessons=[ Lesson( id="L-001", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 15, 9, 0), subject="Mathématiques", teachers=("Prof Dupont",), rooms=("Salle 101",), status=LessonStatus.CANCELLED, group=None, content=None, ) ], homeworks=[], school_events=[], messages=[], target_date=datetime(2026, 1, 15).date(), generated_at=datetime(2026, 1, 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 assert result.status == CalDAVSyncStatus.SUCCESS assert result.added == 1 assert len(fake_caldav_server.get_events()) == 1 # Vérifie que l'événement a le statut CANCELLED event_text = list(fake_caldav_server.get_events().values())[0] assert "STATUS:CANCELLED" in event_text def test_unmanaged_event_untouched( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste qu'un événement non géré n'est pas supprimé.""" # Pré-remplir le serveur avec un événement NON géré unmanaged_event = _create_vevent_text( uid="UNMANAGED-001", summary="Événement personnel", start=datetime(2026, 1, 15, 10, 0), end=datetime(2026, 1, 15, 11, 0), managed=False, # Non géré ) fake_caldav_server._events["UNMANAGED-001"] = unmanaged_event # Données Pronote sans ce cours pronote_data = PronoteData( lessons=[], homeworks=[], school_events=[], messages=[], target_date=datetime(2026, 1, 15).date(), generated_at=datetime(2026, 1, 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 : l'événement non géré est toujours présent assert result.status == CalDAVSyncStatus.SKIPPED assert len(fake_caldav_server.get_events()) == 1 assert "UNMANAGED-001" in fake_caldav_server.get_events() def test_caldav_not_configured( self, fake_caldav_server: FakeCalDAVServer, ) -> None: """Teste que la synchronisation est ignorée si CalDAV n'est pas configuré.""" # Configuration sans CalDAV full_settings = Settings( caldav=CalDAVSettings( url=None, username=None, password=None, calendar_path="/pronote-sync/", ), app=AppSettings( dry_run=False, log_level="INFO", sync_past_days=7, sync_future_days=30, ), ) # Données Pronote pronote_data = PronoteData( lessons=[ Lesson( id="L-001", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 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, 1, 15).date(), generated_at=datetime(2026, 1, 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 assert result.status == CalDAVSyncStatus.SKIPPED assert result.added == 0 assert result.updated == 0 assert result.removed == 0 def test_homework_sync( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste la synchronisation des devoirs.""" # Données Pronote avec un devoir pronote_data = PronoteData( lessons=[], homeworks=[ Homework( id="hw-001", subject="Histoire", teachers=("Prof Bernard",), assigned_on=datetime(2026, 1, 15).date(), due_on=datetime(2026, 1, 20).date(), text="Lire le chapitre 5", html="
Lire le chapitre 5
", ) ], school_events=[], messages=[], target_date=datetime(2026, 1, 15).date(), generated_at=datetime(2026, 1, 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 assert result.status == CalDAVSyncStatus.SUCCESS assert result.added == 1 assert len(fake_caldav_server.get_events()) == 1 def test_school_event_sync( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste la synchronisation des événements scolaires.""" # Données Pronote avec un événement scolaire pronote_data = PronoteData( lessons=[], homeworks=[], school_events=[ SchoolEvent( kind=SchoolEventKind.HOLIDAY, label="Vacances de Noël", from_date=datetime(2026, 12, 20).date(), to_date=datetime(2027, 1, 5).date(), ) ], messages=[], target_date=datetime(2026, 1, 15).date(), generated_at=datetime(2026, 1, 15, 0, 0), ) # Synchronisation result = synchronize( pronote_data=pronote_data, settings=full_settings, client_factory=fake_caldav_server.client_factory, now=datetime(2026, 12, 20, 12, 0), ) # Vérifications assert result.status == CalDAVSyncStatus.SUCCESS assert result.added == 1 assert len(fake_caldav_server.get_events()) == 1 def test_multiple_operations( self, fake_caldav_server: FakeCalDAVServer, full_settings: Settings, ) -> None: """Teste une synchronisation avec plusieurs opérations (ajout, mise à jour, suppression).""" # Pré-remplir le serveur avec des événements existants existing_lesson = _create_vevent_text( uid="L-001", summary="Mathématiques", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 15, 9, 0), managed=True, ) existing_homework = _create_vevent_text( uid="homework-hw-001", summary="Devoir: Histoire", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 15, 18, 0), managed=True, ) existing_unmanaged = _create_vevent_text( uid="UNMANAGED-001", summary="Événement personnel", start=datetime(2026, 1, 15, 10, 0), end=datetime(2026, 1, 15, 11, 0), managed=False, ) fake_caldav_server._events["L-001"] = existing_lesson fake_caldav_server._events["homework-hw-001"] = existing_homework fake_caldav_server._events["UNMANAGED-001"] = existing_unmanaged # Données Pronote : # - L-001 : modifié (mise à jour) # - L-002 : nouveau (ajout) # - hw-001 : supprimé (suppression) # - hw-002 : nouveau (ajout) pronote_data = PronoteData( lessons=[ Lesson( id="L-001", start=datetime(2026, 1, 15, 8, 0), end=datetime(2026, 1, 15, 9, 0), subject="Physique", # Modifié teachers=("Prof Dupont",), rooms=("Salle 101",), status=LessonStatus.NORMAL, group=None, content=None, ), Lesson( id="L-002", start=datetime(2026, 1, 16, 8, 0), end=datetime(2026, 1, 16, 9, 0), subject="Français", teachers=("Prof Martin",), rooms=("Salle 202",), status=LessonStatus.NORMAL, group=None, content=None, ), ], homeworks=[ Homework( id="hw-002", subject="Mathématiques", teachers=("Prof Dupont",), assigned_on=datetime(2026, 1, 15).date(), due_on=datetime(2026, 1, 20).date(), text="Exercices page 45", html="Exercices page 45
", ) ], school_events=[], messages=[], target_date=datetime(2026, 1, 15).date(), generated_at=datetime(2026, 1, 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 assert result.status == CalDAVSyncStatus.SUCCESS assert result.added == 2 # L-002 et hw-002 assert result.updated == 1 # L-001 assert result.removed == 1 # hw-001 # Vérifie que l'événement non géré est toujours présent 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