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>
930 lines
31 KiB
Python
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
|