Files
college-infos/tests/integration/test_caldav_sync.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

930 lines
31 KiB
Python

"""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="<p>Lire le chapitre 5</p>",
)
],
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="<p>Exercices page 45</p>",
)
],
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