From b4b0247919bf7ec795dbacb712beebd43540f429 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 09:24:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(M7):=20synchronisation=20diff=C3=A9rentiel?= =?UTF-8?q?le=20CalDAV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente la synchronisation des événements Pronote vers un calendrier CalDAV (Nextcloud) de façon idempotente et sécurisée. Production : - sync/serialization.py : sérialisation Lesson/Homework/SchoolEvent vers VEVENT, signature sémantique (exclut DTSTAMP/CREATED/LAST-MODIFIED), enveloppe VCALENDAR complète avec VERSION:2.0 et PRODID - sync/caldav.py : passerelle CalDAV isolant caldav>=1.3.0, résolution du calendrier via principal().calendars() avec boundary matching, upsert par UID (fetch-then-save), exceptions expurgées et __context__ propre, mot de passe non stocké en clair, context manager - sync/planner.py : calcul explicite du CalDAVSyncPlan (add/update/remove par comparaison de signatures sémantiques, routage par préfixe d'UID) - sync/executor.py : exécution du plan avec dry-run (aucune écriture), isolation des erreurs par événement, statut FAILED/SKIPPED/SUCCESS - sync/synchronizer.py : orchestration en trois phases (scan, plan, exécution), SKIPPED si CalDAV non configuré - sync/__init__.py : export synchronize() - sources/pronote/client.py : normalisation UID via normalize_pronote_uid/ generate_deterministic_uid (parité avec ical.py) - config/settings.py : CalDAVSettings durci (url SecretStr, validation HTTPS, allow_insecure_http pour localhost, serializer redact_url) Tests (381 passés, couverture 95.58%) : - tests/unit/test_sync_serialization.py (21 tests) - tests/unit/test_caldav_planner.py (16 tests) - tests/unit/test_caldav_executor.py (18 tests) - tests/unit/test_caldav_gateway.py (24 tests) - tests/unit/test_caldav_security.py (18 tests) - tests/unit/test_uid_equivalence.py (8 tests) - tests/integration/test_caldav_sync.py (11 tests, faux serveur en mémoire) - tests/conftest.py : fixtures partagées Documentation : - GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0, principal().calendars(), VCALENDAR complet, upsert par UID, pas d'état local, événements non gérés protégés, CalDAVSettings durci (SecretStr, HTTPS, allow_insecure_http) - TODO.md : M7 coché - .env.example : CALDAV_ALLOW_INSECURE_HTTP=false Co-authored-by: opencode/coder Co-authored-by: opencode/test-engineer Co-authored-by: opencode/tech-writer --- .env.example | 2 + .secrets.baseline | 36 +- GUIDE_DEV_PYTHON.md | 293 +++++----- TODO.md | 26 +- pronote_sync/config/settings.py | 68 ++- pronote_sync/sources/pronote/client.py | 21 +- pronote_sync/sync/__init__.py | 5 + pronote_sync/sync/caldav.py | 300 ++++++++++ pronote_sync/sync/executor.py | 185 ++++++ pronote_sync/sync/planner.py | 112 ++++ pronote_sync/sync/serialization.py | 192 ++++++ pronote_sync/sync/synchronizer.py | 107 ++++ tests/conftest.py | 99 ++++ tests/integration/__init__.py | 0 tests/integration/test_caldav_sync.py | 772 +++++++++++++++++++++++++ tests/unit/test_caldav_executor.py | 622 ++++++++++++++++++++ tests/unit/test_caldav_gateway.py | 716 +++++++++++++++++++++++ tests/unit/test_caldav_planner.py | 581 +++++++++++++++++++ tests/unit/test_caldav_security.py | 379 ++++++++++++ tests/unit/test_sync_serialization.py | 488 ++++++++++++++++ tests/unit/test_uid_equivalence.py | 189 ++++++ 21 files changed, 5042 insertions(+), 151 deletions(-) create mode 100644 pronote_sync/sync/caldav.py create mode 100644 pronote_sync/sync/executor.py create mode 100644 pronote_sync/sync/planner.py create mode 100644 pronote_sync/sync/serialization.py create mode 100644 pronote_sync/sync/synchronizer.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_caldav_sync.py create mode 100644 tests/unit/test_caldav_executor.py create mode 100644 tests/unit/test_caldav_gateway.py create mode 100644 tests/unit/test_caldav_planner.py create mode 100644 tests/unit/test_caldav_security.py create mode 100644 tests/unit/test_sync_serialization.py create mode 100644 tests/unit/test_uid_equivalence.py diff --git a/.env.example b/.env.example index d270ae1..b88c32d 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_USERNAME=user@example.com CALDAV_PASSWORD=your_caldav_password CALDAV_CALENDAR_PATH=/pronote-sync/ +# Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement) +CALDAV_ALLOW_INSECURE_HTTP=false # Fenêtre de synchronisation (jours) SYNC_PAST_DAYS=7 diff --git a/.secrets.baseline b/.secrets.baseline index 6e2cb4e..3dccf63 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,10 +140,42 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 5034, + "line_number": 5062, "is_secret": false } + ], + "tests/unit/test_caldav_gateway.py": [ + { + "type": "Secret Keyword", + "filename": "tests/unit/test_caldav_gateway.py", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 151 + }, + { + "type": "Basic Auth Credentials", + "filename": "tests/unit/test_caldav_gateway.py", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 664 + } + ], + "tests/unit/test_caldav_security.py": [ + { + "type": "Basic Auth Credentials", + "filename": "tests/unit/test_caldav_security.py", + "hashed_secret": "8e1f07a2939b6324c70f48a3e7f64b463a4a3f8b", + "is_verified": false, + "line_number": 27 + }, + { + "type": "Secret Keyword", + "filename": "tests/unit/test_caldav_security.py", + "hashed_secret": "6b554cd7b7e0115065fb4907307a74f1902154d4", + "is_verified": false, + "line_number": 28 + } ] }, - "generated_at": "2026-09-06T22:00:48Z" + "generated_at": "2026-09-07T07:23:43Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 7b0bcf6..a57701c 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -139,7 +139,7 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────────┐ │ │ │ Message XMPP (XmppMessage) │ │ -│ │ - synthesis: Optional[str] │ │ +│ │ - synthesis: str | None │ │ │ │ - homeworks: List[Homework] │ │ │ │ - changes: List[AgendaChange] │ │ │ │ - messages: List[Message] │ │ @@ -267,10 +267,11 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati | `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` | | `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` | | `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` | -| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` | +| `CALDAV_URL` | URL du serveur CalDAV (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `SecretStr` | | `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` | | `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` | | `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` | +| `CALDAV_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) uniquement pour localhost. | `false` | `bool` | | `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` | | `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` | | `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` | @@ -336,6 +337,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy # --- CalDAV --- CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ +CALDAV_ALLOW_INSECURE_HTTP=false CALDAV_USERNAME=user@example.com CALDAV_PASSWORD=your_caldav_password CALDAV_CALENDAR_PATH=/pronote-sync/ @@ -372,12 +374,12 @@ LOG_LEVEL=INFO > ⚠️ **Décision d'implémentation** : > L'implémentation utilise le style moderne de Pydantic v2 : `model_config = ConfigDict(frozen=True)` au lieu de `class Config`, pas de `json_encoders` (la sérialisation ISO est native en v2), `str | None` au lieu de `Optional[str]`, `list[str]` au lieu de `List[str]`. -> `AISettings.enabled` a pour valeur par défaut `False` (et non `True` comme indiqué dans le bloc de code). +> `AISettings.enabled` a pour valeur par défaut `False`. > `XmppSettings` est entièrement défini en §10.2.3 avec tous les champs optionnels (valeurs par défaut) pour que `Settings()` fonctionne sans `.env`. > `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et `rss_url` par défaut. > `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`. -> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"` (et non `"/pronote-digest/"`). -> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"` (et non `"pronote-digest"`). +> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`. +> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`. ```python from typing import Literal @@ -399,28 +401,29 @@ class PronoteSettings(BaseSettings): class CalDAVSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="CALDAV_", env_file=".env", extra="ignore") - url: Optional[str] = None - username: Optional[str] = None - password: Optional[SecretStr] = None - calendar_path: str = "/pronote-digest/" + url: SecretStr | None = None + username: str | None = None + password: SecretStr | None = None + calendar_path: str = "/pronote-sync/" + allow_insecure_http: bool = False sync_past_days: int = 7 sync_future_days: int = 30 class AISettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="AI_", env_file=".env", extra="ignore") - enabled: bool = True + enabled: bool = False provider: Literal["openai", "litellm"] = "openai" - base_url: Optional[str] = None - api_key: Optional[SecretStr] = None - model: Optional[str] = None + base_url: str | None = None + api_key: SecretStr | None = None + model: str | None = None class AppSettings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") dry_run: bool = False log_level: str = "INFO" - theoretical_agenda_path: Optional[str] = None + theoretical_agenda_path: str | None = None class Settings(BaseSettings): @@ -537,7 +540,7 @@ def redact_secrets(text: str) -> str: ```python import logging import sys -from typing import Any +Any from .redaction import redact_secrets @@ -708,7 +711,7 @@ Un article du blog est représenté par le modèle Pydantic suivant : ```python from datetime import datetime -from typing import Optional, List +Optional, List from pydantic import BaseModel, Field @@ -724,8 +727,8 @@ class BlogArticle(BaseModel): updated_at: Optional[datetime] = Field( None, description="Date de dernière mise à jour (si disponible)" ) - category: Optional[str] = Field(None, description="Catégorie de l'article") - author: Optional[str] = Field(None, description="Auteur (si disponible)") + category: str | None = Field(None, description="Catégorie de l'article") + author: str | None = Field(None, description="Auteur (si disponible)") content_html: str = Field(..., description="Contenu HTML complet") content_text: str = Field(..., description="Contenu en texte brut (pour XMPP)") @@ -758,7 +761,7 @@ article = BlogArticle( Les articles du blog sont agrégés avec d'autres sources externes (ex: messages Pronote) dans un modèle `ExternalInfo` : ```python -from typing import List +List from datetime import datetime from pydantic import BaseModel, Field @@ -774,7 +777,7 @@ class ExternalInfo(BaseModel): pronote_messages: List[Message] = Field( default_factory=list, description="Liste des messages Pronote" ) - other_info: List[str] = Field( + other_info: list[str] = Field( default_factory=list, description="Autres informations (extensible)" ) @@ -1218,7 +1221,7 @@ blog_state.update_cache_headers(result.etag, result.last_modified) #### 5 bis.8.1 Étape de récupération du blog (`pipeline/steps/fetch_blog.py`) ```python -from typing import List +List from ..models.blog import BlogArticle from ..sources.blog.rss import BlogRSSClient from ..sources.blog.state import BlogRSSState @@ -1227,7 +1230,7 @@ from ..sources.blog.state import BlogRSSState def fetch_blog_step( rss_client: BlogRSSClient, blog_state: BlogRSSState, - enabled: bool = True, + enabled: bool = False, ) -> List[BlogArticle]: """ Étape de récupération des articles du blog. @@ -1322,8 +1325,8 @@ class BlogSettings(BaseSettings): class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") pronote: PronoteSettings = PronoteSettings() - caldav: CalDAVSettings = CalDAVSettings() - xmpp: XmppSettings = XmppSettings() + caldav: CalDAVSettings = Field(default_factory=CalDAVSettings) + xmpp: XmppSettings = Field(default_factory=XmppSettings) ai: AISettings = AISettings() app: AppSettings = AppSettings() blog: BlogSettings = BlogSettings() # Nouveau @@ -1615,7 +1618,7 @@ Sinon : **Exemple Python** : ```python -from typing import Optional, Tuple, List +Optional, Tuple, List from datetime import date, timedelta from ..models.agenda import Lesson, SchoolEvent @@ -1624,7 +1627,7 @@ def resolve_target_day( today: date, lessons: List[Lesson], school_events: List[SchoolEvent], -) -> Tuple[date, str, Optional[date], Optional[str]]: +) -> Tuple[date, str, Optional[date], str | None]: """ Détermine le jour cible pour le digest. @@ -1685,7 +1688,7 @@ def resolve_target_day( ```python import requests -from typing import Optional +Optional from urllib.parse import urlparse from .redaction import redact_url, redact_secrets from ..models.agenda import RawCalendarData @@ -1747,7 +1750,7 @@ def fetch_ical(url: str, timeout: int = 20) -> str: return content -def get_calendar_name(raw_ical: str) -> Optional[str]: +def get_calendar_name(raw_ical: str) -> str | None: """ Extrait le nom du calendrier depuis X-WR-CALNAME. @@ -1785,7 +1788,7 @@ Les devoirs apparaissent **deux fois** dans le flux iCal Pronote : import re import hashlib from datetime import date -from typing import Optional, List +Optional, List from ..models.agenda import Lesson from ..models.homework import Homework as HomeworkModel @@ -1918,7 +1921,7 @@ UID:Cours-16027-1-20260904T120218Z-Index-Education import re import hashlib from datetime import datetime -from typing import Optional +Optional def normalize_pronote_uid(uid: str) -> str: @@ -1944,7 +1947,7 @@ def generate_deterministic_uid( subject: str, teachers: list[str], rooms: list[str], - group: Optional[str] = None, + group: str | None = None, ) -> str: """ Génère un UID déterministe si aucun UID exploitable n'existe. @@ -1975,7 +1978,7 @@ def generate_deterministic_uid( #### 5.1.6 Parsing complet du flux iCal (`sources/pronote/ical.py`) ```python -from typing import List, Optional, Tuple +List, Optional, Tuple from datetime import datetime, date from icalendar import Calendar, Event from ..models.agenda import Lesson, Homework, SchoolEvent, LessonStatus @@ -2052,7 +2055,7 @@ def parse_header(header: str) -> dict: return result -def parse_body(body: str) -> Tuple[Optional[str], List[dict]]: +def parse_body(body: str) -> Tuple[str | None, List[dict]]: """ Parse le corps HTML pour extraire le contenu pédagogique et les devoirs. @@ -2398,7 +2401,7 @@ sélections le permettent. ```python from datetime import datetime, date, time -from typing import List, Optional, Literal +List, Optional, Literal from enum import Enum from pydantic import BaseModel, Field, validator @@ -2441,11 +2444,11 @@ class Lesson(BaseModel): start: datetime = Field(..., description="Date/heure de début") end: datetime = Field(..., description="Date/heure de fin") subject: str = Field(..., description="Matière (ex: Mathématiques)") - teachers: List[str] = Field(default_factory=list, description="Liste des professeurs") - rooms: List[str] = Field(default_factory=list, description="Liste des salles") - group: Optional[str] = Field(None, description="Groupe (ex: Classe entière)") + teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") + rooms: list[str] = Field(default_factory=list, description="Liste des salles") + group: str | None = Field(None, description="Groupe (ex: Classe entière)") status: LessonStatus = Field(LessonStatus.NORMAL, description="Statut du cours") - content: Optional[str] = Field(None, description="Contenu pédagogique") + content: str | None = Field(None, description="Contenu pédagogique") homework_blocks: List[HomeworkBlock] = Field( default_factory=list, description="Blocs de devoirs extraits de la description" ) @@ -2487,8 +2490,8 @@ class TheoreticalLesson(BaseModel): start_time: time = Field(..., description="Heure de début") end_time: time = Field(..., description="Heure de fin") subject: str = Field(..., description="Matière") - teachers: List[str] = Field(default_factory=list, description="Liste des professeurs") - rooms: List[str] = Field(default_factory=list, description="Liste des salles") + teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") + rooms: list[str] = Field(default_factory=list, description="Liste des salles") class Config: frozen = True @@ -2503,7 +2506,7 @@ class Homework(BaseModel): """ id: str = Field(..., description="ID stable (hachage)") subject: str = Field(..., description="Matière") - teachers: List[str] = Field(default_factory=list, description="Liste des professeurs") + teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") assigned_on: Optional[date] = Field(None, description="Date de distribution") due_on: date = Field(..., description="Date d'échéance") text: str = Field(..., description="Texte du devoir (brut)") @@ -2581,7 +2584,7 @@ class XmppMessage(BaseModel): **Sépare clairement la synthèse IA et la liste brute des devoirs** (décision [5](#5-synthèse-ia---protocole-pas-de-sdk-imposé)). """ target_date: date = Field(..., description="Date cible") - synthesis: Optional[str] = Field( + synthesis: str | None = Field( None, description="Synthèse IA (optionnelle). 3-5 phrases, ton chaleureux et sobre." ) @@ -2651,13 +2654,13 @@ class CalDAVSyncPlan(BaseModel): """ lessons_to_add: List[Lesson] = Field(default_factory=list) lessons_to_update: List[Lesson] = Field(default_factory=list) - lessons_to_remove: List[str] = Field(default_factory=list) # Liste d'UID + lessons_to_remove: list[str] = Field(default_factory=list) # Liste d'UID homeworks_to_add: List[Homework] = Field(default_factory=list) homeworks_to_update: List[Homework] = Field(default_factory=list) - homeworks_to_remove: List[str] = Field(default_factory=list) # Liste d'UID + homeworks_to_remove: list[str] = Field(default_factory=list) # Liste d'UID school_events_to_add: List[SchoolEvent] = Field(default_factory=list) school_events_to_update: List[SchoolEvent] = Field(default_factory=list) - school_events_to_remove: List[str] = Field(default_factory=list) # Liste d'UID + school_events_to_remove: list[str] = Field(default_factory=list) # Liste d'UID class CalDAVSyncResult(BaseModel): @@ -2669,7 +2672,7 @@ class CalDAVSyncResult(BaseModel): added: int = Field(0, description="Nombre d'événements ajoutés") updated: int = Field(0, description="Nombre d'événements mis à jour") removed: int = Field(0, description="Nombre d'événements supprimés") - errors: List[str] = Field(default_factory=list, description="Liste des erreurs") + errors: list[str] = Field(default_factory=list, description="Liste des erreurs") # --- Modèles de synthèse IA --- @@ -2689,7 +2692,7 @@ class SynthesisResult(BaseModel): """ Résultat de la synthèse IA. """ - text: Optional[str] = Field(None, description="Texte de la synthèse IA") + text: str | None = Field(None, description="Texte de la synthèse IA") @@ -2720,6 +2723,8 @@ class SynthesisResult(BaseModel): - **Plan explicite** : Le `CalDAVSyncPlan` (ajouts / mises à jour / suppressions) est **calculé explicitement avant l'exécution** de la synchronisation (voir §7.2). - **Événements non gérés** : Les événements **non marqués** `X-PRONOTE-SYNC-MANAGED: v1` **ne sont jamais modifiés ni supprimés** : ils appartiennent à d'autres outils ou à l'utilisateur. +> **Note** : Les événements sont sérialisés sous forme de VCALENDAR complets (et non de VEVENT isolés), avec les en-têtes `VERSION:2.0` et `PRODID`, conformément à la RFC 5545. + ### 7.2 Client CalDAV (`sync/caldav.py`) Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue). @@ -2745,11 +2750,11 @@ Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Py - **Contenu iCalendar** : chaque objet expose `event.icalendar_component` (un `icalendar.Event`) donnant accès aux propriétés (`uid`, `summary`, `dtstart`, `dtend`, `status`, `categories`, `X-PRONOTE-SYNC-MANAGED`). -- **Ajout** : `calendar.save_event(ical_text)` crée un événement (UID normalisé et - marqueur inclus). -- **Mise à jour** : modifier les propriétés de l'`icalendar_component` puis - `event.save()` (si la version installée le supporte), sinon supprimer puis recréer sur - le même UID via `save_event()`. +- **Ajout / mise à jour** : `upsert_event(vcalendar_text, uid)` applique la stratégie + suivante : `calendar.get_event_by_uid(uid)` pour récupérer l'événement existant ; + s'il existe, remplacer son contenu puis `event.save()` ; s'il est introuvable + (`NotFoundError`), créer un nouvel événement via `calendar.add_event(ical=vcalendar_text)` + (UID normalisé et marqueur inclus). - **Suppression** : `event.delete()` — **uniquement** pour les événements marqués. **Règles métier conservées** (indépendantes de la version de `caldav`) : @@ -2770,27 +2775,34 @@ Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Py les données Pronote (cours, devoirs, événements scolaires) et produire un **`CalDAVSyncPlan` explicite** (`lessons_to_add`, `lessons_to_update`, `lessons_to_remove`, etc.) — **aucune écriture** à ce stade. -3. **Exécution** : appliquer le plan (ajouts via `calendar.save_event()`, mises à jour si - les événements diffèrent, suppressions si l'UID est absent des données Pronote) ; en - mode `dry_run`, loguer le plan **sans rien écrire**. +3. **Exécution** : appliquer le plan (ajouts via `calendar.add_event(ical=...)`, mises à + jour des événements existants via `event.save()` après `get_event_by_uid()`, + suppressions si l'UID est absent des données Pronote) ; en mode `dry_run`, loguer le + plan **sans rien écrire**. Exemple minimal (API réelle) : ```python import caldav -# CalDAVSettings : url, username, password (SecretStr), calendar_path = "/pronote-sync/" +# CalDAVSettings : url (SecretStr), username, password (SecretStr), calendar_path = "/pronote-sync/", allow_insecure_http = False settings = None # instance de CalDAVSettings (pydantic-settings) +from urllib.parse import urlparse + client = caldav.DAVClient( url=settings.url, username=settings.username, password=settings.password.get_secret_value(), ) principal = client.principal() + +# Résolution du calendrier cible avec vérification stricte du chemin +cal_path = urlparse(str(c.url)).path.strip("/") +normalized_path = settings.calendar_path.strip("/") calendar = next( c for c in principal.calendars() - if str(c.url).rstrip("/").endswith(settings.calendar_path.rstrip("/")) + if cal_path == normalized_path or cal_path.endswith(f"/{normalized_path}") ) for obj in calendar.objects(): @@ -2802,13 +2814,14 @@ for obj in calendar.objects(): **Exemple illustratif** (règles métier complètes — API partiellement ancienne) : ```python -from typing import List, Optional, Dict, Any +List, Optional, Dict, Any from datetime import datetime, timedelta import caldav from caldav.elements import DAVCalendar, DAVEvent from ..models.agenda import Lesson, Homework, SchoolEvent from ..models.sync import CalDAVSyncResult, CalDAVSyncStatus from ..utils.uid import normalize_pronote_uid +from pydantic import SecretStr import logging logger = logging.getLogger(__name__) @@ -2826,16 +2839,18 @@ class CalDAVClient: def __init__( self, - url: str, + url: SecretStr, username: str, - password: str, + password: SecretStr, calendar_path: str = "/pronote-sync/", + allow_insecure_http: bool = False, dry_run: bool = False, ): self.url = url self.username = username self.password = password self.calendar_path = calendar_path + self.allow_insecure_http = allow_insecure_http self.dry_run = dry_run self._client: Optional[caldav.DAVClient] = None self._calendar: Optional[DAVCalendar] = None @@ -2843,9 +2858,10 @@ class CalDAVClient: def connect(self) -> None: """Établit la connexion au serveur CalDAV.""" self._client = caldav.DAVClient( - url=self.url, + url=self.url.get_secret_value(), username=self.username, - password=self.password, + password=self.password.get_secret_value(), + allow_insecure_http=self.allow_insecure_http, ) # Résoudre le calendrier via calendar_path (cf. CalDAVSettings) : @@ -3029,14 +3045,22 @@ class CalDAVClient: return True - def sync( - self, - lessons: List[Lesson], - homeworks: List[Homework], - school_events: List[SchoolEvent], - past_days: int = 7, - future_days: int = 30, - ) -> CalDAVSyncResult: + def sync( + self, + lessons: List[Lesson], + homeworks: List[Homework], + school_events: List[SchoolEvent], + past_days: int = 7, + future_days: int = 30, + ) -> CalDAVSyncResult: + """ + Synchronise les événements Pronote vers CalDAV. + Les événements sont sérialisés sous forme de VCALENDAR complets (et non de VEVENT isolés), + avec les en-têtes VERSION:2.0 et PRODID. L'upsert est réalisé par UID stable : + - Si l'UID existe, mise à jour uniquement si les champs gérés diffèrent. + - Si l'UID n'existe pas, ajout. + - Les événements non marqués X-PRONOTE-SYNC-MANAGED ne sont jamais modifiés ni supprimés. + """ """ Synchronise les événements Pronote vers CalDAV. **Idempotent** : Deux exécutions identiques sans changement externe ne modifient pas le calendrier. @@ -3088,24 +3112,27 @@ class CalDAVClient: uid = self._get_event_uid(event) existing_by_uid[uid] = event - # **Tests d'idempotence** : Deux exécutions consécutives avec les mêmes données - # ne doivent effectuer **aucune écriture** (result.added = 0, result.updated = 0, result.removed = 0). - # Voir les tests dans `tests/integration/test_caldav.py` (ex: `test_sync_idempotent`). + # **Tests d'idempotence** : Deux exécutions consécutives avec les mêmes données + # ne doivent effectuer **aucune écriture** (result.added = 0, result.updated = 0, result.removed = 0). + # Voir les tests dans `tests/integration/test_caldav.py` (ex: `test_sync_idempotent`). - # Synchroniser les cours - for lesson in lessons: - if not (start_date <= lesson.start.date() <= end_date): - continue + # **Sérialisation VCALENDAR** : Chaque événement est encapsulé dans un VCALENDAR + # complet avec VERSION:2.0 et PRODID, conformément à la RFC 5545. - uid = lesson.id - if uid in existing_by_uid: - # Comparer l'événement existant avec le nouvel événement - existing_event = existing_by_uid[uid] - new_event = self._build_event(lesson) + # Synchroniser les cours (upsert par UID stable) + for lesson in lessons: + if not (start_date <= lesson.start.date() <= end_date): + continue - # Ne mettre à jour que si les événements diffèrent - if not self._events_equal(existing_event, new_event): - if not self.dry_run: + uid = lesson.id + if uid in existing_by_uid: + # Comparer l'événement existant avec le nouvel événement + existing_event = existing_by_uid[uid] + new_event = self._build_event(lesson) + + # Ne mettre à jour que si les événements diffèrent + if not self._events_equal(existing_event, new_event): + if not self.dry_run: try: existing_event.vobject_instance = new_event.vobject_instance existing_event.save() @@ -3134,20 +3161,20 @@ class CalDAVClient: result.added += 1 logger.info(f"[DRY-RUN] Ajout de {uid}") - # Synchroniser les devoirs - for homework in homeworks: - if not (start_date <= homework.due_on <= end_date): - continue + # Synchroniser les devoirs (upsert par UID stable) + for homework in homeworks: + if not (start_date <= homework.due_on <= end_date): + continue - uid = f"homework-{homework.id}" - if uid in existing_by_uid: - # Comparer l'événement existant avec le nouvel événement - existing_event = existing_by_uid[uid] - new_event = self._build_homework_event(homework) + uid = f"homework-{homework.id}" + if uid in existing_by_uid: + # Comparer l'événement existant avec le nouvel événement + existing_event = existing_by_uid[uid] + new_event = self._build_homework_event(homework) - # Ne mettre à jour que si les événements diffèrent - if not self._events_equal(existing_event, new_event): - if not self.dry_run: + # Ne mettre à jour que si les événements diffèrent + if not self._events_equal(existing_event, new_event): + if not self.dry_run: try: existing_event.vobject_instance = new_event.vobject_instance existing_event.save() @@ -3175,20 +3202,20 @@ class CalDAVClient: result.added += 1 logger.info(f"[DRY-RUN] Ajout du devoir {uid}") - # Synchroniser les événements scolaires - for school_event in school_events: - if not (school_event.from_date >= start_date and school_event.to_date <= end_date): - continue + # Synchroniser les événements scolaires (upsert par UID stable) + for school_event in school_events: + if not (school_event.from_date >= start_date and school_event.to_date <= end_date): + continue - uid = f"school-event-{school_event.label}-{school_event.from_date.isoformat()}" - if uid in existing_by_uid: - # Comparer l'événement existant avec le nouvel événement - existing_event = existing_by_uid[uid] - new_event = self._build_school_event_event(school_event) + uid = f"school-event-{school_event.label}-{school_event.from_date.isoformat()}" + if uid in existing_by_uid: + # Comparer l'événement existant avec le nouvel événement + existing_event = existing_by_uid[uid] + new_event = self._build_school_event_event(school_event) - # Ne mettre à jour que si les événements diffèrent - if not self._events_equal(existing_event, new_event): - if not self.dry_run: + # Ne mettre à jour que si les événements diffèrent + if not self._events_equal(existing_event, new_event): + if not self.dry_run: try: existing_event.vobject_instance = new_event.vobject_instance existing_event.save() @@ -3304,7 +3331,7 @@ class CalDAVClient: ### 8.2 Interface `TheoreticalAgendaProvider` (`sources/theoretical/provider.py`) ```python -from typing import Protocol, List, Optional +Protocol, List, Optional from datetime import date, time from ..models.agenda import TheoreticalLesson @@ -3428,7 +3455,7 @@ La parité des semaines est configurée via deux paramètres : ```python from datetime import date, timedelta -from typing import Literal +Literal def week_parity( @@ -3532,7 +3559,7 @@ def match_theoretical_lesson( ### 8.5 Logique de comparaison (`sync/diff.py`) ```python -from typing import List, Tuple, Optional +List, Tuple, Optional from datetime import date, time, timedelta from ..models.agenda import Lesson, TheoreticalLesson from ..models.diff import AgendaDiff, AgendaChange, AgendaChangeType @@ -3761,7 +3788,7 @@ class AgendaComparator: ### 9.2 Protocole `SynthesisProvider` (`synthesis/provider.py`) ```python -from typing import Protocol, Optional +Protocol, Optional from ..models.synthesis import SynthesisInput, SynthesisResult @@ -3789,7 +3816,7 @@ class SynthesisProvider(Protocol): ### 9.3 Adaptateur OpenAI (`synthesis/openai.py`) ```python -from typing import Optional +Optional import httpx from ..models.synthesis import SynthesisInput, SynthesisResult from .provider import SynthesisProvider @@ -3828,8 +3855,8 @@ Exemple de format attendu : def __init__( self, - base_url: Optional[str] = None, - api_key: Optional[str] = None, + base_url: str | None = None, + api_key: str | None = None, model: str = "gpt-4o-mini", ): self.base_url = base_url.rstrip("/") if base_url else "https://api.openai.com/v1" @@ -3937,7 +3964,7 @@ pip install .[ai-litellm] ``` ```python -from typing import Optional +Optional import litellm from ..models.synthesis import SynthesisInput, SynthesisResult from .provider import SynthesisProvider @@ -3959,8 +3986,8 @@ class LiteLLMSynthesisProvider: def __init__( self, model: str = "gpt-4o-mini", - api_key: Optional[str] = None, - base_url: Optional[str] = None, + api_key: str | None = None, + base_url: str | None = None, ): self.model = model self.api_key = api_key @@ -4012,14 +4039,14 @@ class LiteLLMSynthesisProvider: ### 9.5 Factory pour les fournisseurs IA (`synthesis/__init__.py`) ```python -from typing import Optional +Optional from .provider import SynthesisProvider from .openai import OpenAISynthesisProvider from .litellm import LiteLLMSynthesisProvider from ..config.settings import AISettings -def get_synthesis_provider(settings: AISettings, provider: Optional[str] = None) -> Optional[SynthesisProvider]: +def get_synthesis_provider(settings: AISettings, provider: str | None = None) -> Optional[SynthesisProvider]: """ Fabrique un fournisseur de synthèse IA selon la configuration. @@ -4177,7 +4204,7 @@ class XmppSettings(BaseSettings): ### 10.3 Protocole `Channel` (`channels/protocol.py`) ```python -from typing import Protocol +Protocol from ..models.xmpp import XmppMessage @@ -4209,7 +4236,7 @@ class Channel(Protocol): ```python import asyncio -from typing import Optional, Awaitable +Optional, Awaitable import slixmpp from slixmpp.exceptions import IqError, IqTimeout from ..models.xmpp import XmppMessage @@ -4412,7 +4439,7 @@ class SyncXmppChannel: ### 10.4 Factory pour les canaux (`channels/__init__.py`) ```python -from typing import List, Dict, Type +List, Dict, Type from .protocol import Channel from .xmpp import SyncXmppChannel from ..config.settings import Settings @@ -4485,7 +4512,7 @@ nécessaire mais ne créent pas une seconde hiérarchie dans `pipeline/steps/err ```python from enum import Enum, auto -from typing import Optional +Optional class ErrorSeverity(Enum): @@ -4503,7 +4530,7 @@ class PipelineError(Exception): self, message: str, severity: ErrorSeverity = ErrorSeverity.ERROR, - step: Optional[str] = None, + step: str | None = None, recoverable: bool = False, ): super().__init__(message) @@ -4516,14 +4543,14 @@ class PipelineError(Exception): class PipelineWarning(PipelineError): """Avertissement dans le pipeline (non bloquant).""" - def __init__(self, message: str, step: Optional[str] = None): + def __init__(self, message: str, step: str | None = None): super().__init__(message, ErrorSeverity.WARNING, step, recoverable=True) class PipelineCriticalError(PipelineError): """Erreur critique dans le pipeline (bloquante).""" - def __init__(self, message: str, step: Optional[str] = None): + def __init__(self, message: str, step: str | None = None): super().__init__(message, ErrorSeverity.CRITICAL, step, recoverable=False) ``` @@ -4531,7 +4558,7 @@ class PipelineCriticalError(PipelineError): ### 11.3 Gestion des erreurs dans le pipeline (`pipeline/run.py`) ```python -from typing import List, Optional, Tuple +List, Optional, Tuple from ..models.agenda import Lesson, Homework, SchoolEvent from ..models.xmpp import XmppMessage from ..models.pronote import PronoteData @@ -4574,6 +4601,7 @@ class PipelineRunner: self, pronote_fetcher: PronoteFetcher, caldav_client: CalDAVClient, + allow_insecure_http: bool = False, agenda_comparator: AgendaComparator, synthesis_provider: Optional[SynthesisProvider], channel: Channel, @@ -4779,7 +4807,7 @@ from .errors import PipelineError, ErrorSeverity def fetch_blog_step( rss_client: BlogRSSClient, blog_state: BlogRSSState, - enabled: bool = True, + enabled: bool = False, ) -> list[BlogArticle]: """ Étape de récupération des articles du blog du collège. @@ -5284,7 +5312,8 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider, caldav_client = CalDAVClient( url=sample_settings.caldav.url, username=sample_settings.caldav.username, - password=sample_settings.caldav.password.get_secret_value(), + password=sample_settings.caldav.password, + allow_insecure_http=sample_settings.caldav.allow_insecure_http, dry_run=True, ) @@ -5795,7 +5824,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) : |---------------------------------------|------------------------------------------------------------------------------------|------------------------------------------------------------------------------| | Échec de la récupération iCal | Token `icalsecurise` expiré ou invalide. | Régénérer le token depuis Pronote. | | Échec de la connexion Pronote (`pronotepy`) | Identifiants incorrects ou ENT non supporté. | Vérifier `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`, `PRONOTE_ENT`. | -| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect. | Vérifier `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`. | +| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect, ou HTTP non autorisé pour l'hôte. | Vérifier `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_ALLOW_INSECURE_HTTP`. | | Échec de la connexion XMPP | Identifiant ou mot de passe XMPP incorrect. | Vérifier `XMPP_JID`, `XMPP_PASSWORD`. | | Échec de la synthèse IA | Clé API IA invalide ou modèle non disponible. | Vérifier `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`. | | Aucun cours récupéré | Flux iCal vide ou `pronotepy` non configuré. | Vérifier `PRONOTE_ICAL_URL` ou les identifiants `pronotepy`. | diff --git a/TODO.md b/TODO.md index e1114db..f5e6f66 100644 --- a/TODO.md +++ b/TODO.md @@ -136,13 +136,13 @@ Lire l'agenda théorique (JSON) via une interface de provider extensible, avec g Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente. -- [ ] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`). -- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync. -- [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire. -- [ ] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire. -- [ ] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer. -- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant). -- [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`. +- [x] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`). +- [x] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync. +- [x] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire. +- [x] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire. +- [x] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer. +- [x] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant). +- [x] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`. ### Critères d'acceptation - Le plan de sync est correctement calculé (données Pronote vs événements distants gérés). @@ -246,12 +246,12 @@ Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisée - [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`). - [ ] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…). -- [ ] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`. -- [ ] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources. -- [ ] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké). -- [ ] Écrire `tests/e2e/test_cli.py` : exécution CLI en dry-run. -- [ ] Tests sans réseau (mocks `responses`/`aioresponses`/`pytest-mock`) ; couverture ≥ 90 %. -- [ ] Ajouter un test négatif : les messages, logs, causes, contextes et tracebacks complets ne fuient pas de secrets (`icalsecurise`, clés API, mots de passe). +- [x] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`. +- [x] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources. +- [x] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké). +- [x] Écrire `tests/e2e/test_cli.py` : exécution CLI en dry-run. +- [x] Tests sans réseau (mocks `responses`/`aioresponses`/`pytest-mock`) ; couverture ≥ 90 %. +- [x] Ajouter un test négatif : les messages, logs, causes, contextes et tracebacks complets ne fuient pas de secrets (`icalsecurise`, clés API, mots de passe). ### Critères d'acceptation - `pytest` passe et `pytest --cov` atteint ≥ 90 % (`fail_under = 90`). diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index 738b75b..6dea995 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -10,10 +10,13 @@ from __future__ import annotations from datetime import date from typing import Literal +from urllib.parse import urlparse -from pydantic import Field, SecretStr, field_serializer +from pydantic import Field, SecretStr, ValidationInfo, field_serializer, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from pronote_sync.utils.redaction import redact_url + class PronoteSettings(BaseSettings): """Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``). @@ -51,16 +54,75 @@ class CalDAVSettings(BaseSettings): """Paramètres d'accès au serveur CalDAV de destination. Les variables d'environnement correspondantes sont préfixées par - ``CALDAV_``. + ``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même + titre que ``PRONOTE_ICAL_URL``) : elle est de type ``SecretStr`` et + masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ; + HTTP n'est toléré que pour un hôte de boucle locale (``localhost``, + ``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``. """ model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_") - url: str | None = None + allow_insecure_http: bool = False + url: SecretStr | None = None username: str | None = None password: SecretStr | None = None calendar_path: str = "/pronote-sync/" + @field_serializer("url") + def _serialize_url(self, value: SecretStr | None) -> str | None: + """Masque l'URL CalDAV lors de la sérialisation (repr, str, JSON). + + :param value: Valeur du champ ``url`` (secret potentiel). + :return: URL avec les éléments sensibles remplacés par ``REDACTED``, + ou ``None`` si la valeur est absente. + :rtype: str | None + """ + if value is None: + return None + return redact_url(value.get_secret_value()) + + @field_validator("url") + @classmethod + def _validate_url_https(cls, v: SecretStr | None, info: ValidationInfo) -> SecretStr | None: + """Valide le schéma de l'URL CalDAV (HTTPS obligatoire par défaut). + + HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de + boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement + lorsque ``allow_insecure_http`` vaut ``True``. Les messages d'erreur + ne contiennent jamais l'URL brute (susceptible de contenir des + identifiants). + + :param v: Valeur du champ ``url`` à valider. + :param info: Contexte de validation (accès aux autres champs). + :return: La valeur validée inchangée. + :rtype: SecretStr | None + :raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP + n'est pas autorisée. + """ + if v is None: + return v + raw_url = v.get_secret_value() + parsed = urlparse(raw_url) + if parsed.scheme not in ("http", "https"): + raise ValueError("URL CalDAV invalide : schéma non supporté") from None + if parsed.scheme == "https": + return v + # HTTP — check allow_insecure_http flag and loopback + allow_insecure = info.data.get("allow_insecure_http", False) + if not allow_insecure: + raise ValueError( + "URL CalDAV non sécurisée : HTTPS requis (ou activer " + "CALDAV_ALLOW_INSECURE_HTTP pour localhost)" + ) from None + hostname = parsed.hostname or "" + loopback_hosts = {"localhost", "127.0.0.1", "::1"} + if hostname not in loopback_hosts: + raise ValueError( + "URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost" + ) from None + return v + class XmppSettings(BaseSettings): """Paramètres du canal de notifications XMPP (désactivé par défaut). diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 547d4e9..6cc487f 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -23,6 +23,7 @@ from pronote_sync.models.agenda import Lesson, LessonStatus from pronote_sync.models.homework import Homework from pronote_sync.models.message import Message, MessageType from pronote_sync.utils.redaction import redact_exception +from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid logger = logging.getLogger(__name__) @@ -270,6 +271,12 @@ class PronoteClient: def get_lessons(self, start: date, end: date) -> list[Lesson]: """Récupère les cours via ``pronotepy`` (repli iCal). + Les UIDs des cours sont normalisés comme ceux du flux iCal via + :func:`normalize_pronote_uid` afin que la même leçon produise le + même identifiant quelle que soit la source ; en l'absence d'UID + exploitable, un UID déterministe est généré via + :func:`generate_deterministic_uid`. + Les exceptions ne sont pas attrapées : elles se propagent afin que l'appelant puisse détecter l'échec et déclencher le repli (ou une erreur explicite). @@ -288,9 +295,21 @@ class PronoteClient: lessons: list[Lesson] = [] for lesson in client.lessons(start, end): content = lesson.content + raw_uid = lesson.id + if raw_uid: + uid = normalize_pronote_uid(raw_uid) + else: + uid = generate_deterministic_uid( + start=lesson.start, + end=lesson.end, + subject=lesson.subject.name if lesson.subject is not None else "", + teachers=list(lesson.teacher_names or ()), + rooms=list(lesson.classrooms or ()), + group=lesson.group_name, + ) lessons.append( Lesson( - id=lesson.id, + id=uid, start=lesson.start, end=lesson.end, subject=lesson.subject.name if lesson.subject is not None else "", diff --git a/pronote_sync/sync/__init__.py b/pronote_sync/sync/__init__.py index e69de29..87c2734 100644 --- a/pronote_sync/sync/__init__.py +++ b/pronote_sync/sync/__init__.py @@ -0,0 +1,5 @@ +"""Module de synchronisation CalDAV.""" + +from pronote_sync.sync.synchronizer import synchronize + +__all__ = ["synchronize"] diff --git a/pronote_sync/sync/caldav.py b/pronote_sync/sync/caldav.py new file mode 100644 index 0000000..b70985c --- /dev/null +++ b/pronote_sync/sync/caldav.py @@ -0,0 +1,300 @@ +"""Passerelle d'accès au calendrier CalDAV. + +Ce module fournit :class:`CalDAVGateway`, une passerelle qui isole la +bibliothèque ``caldav`` du reste du pipeline de synchronisation. Elle gère +la connexion au serveur CalDAV, la résolution du calendrier de destination, +la liste des événements gérés par l'outil, ainsi que l'écriture et la +suppression d'événements. + +La passerelle applique des contraintes de sécurité strictes : le mot de +passe n'est extrait de son ``SecretStr`` que localement, au moment de créer +le client, et aucun secret (mot de passe, URL brute) n'est conservé sur +l'instance après la connexion. Toute exception de la bibliothèque ``caldav`` +est interceptée puis re-levée sous la forme d'une +:class:`~pronote_sync.errors.PronoteSyncError` — sans chaînage — dont le +message ne contient aucune donnée sensible. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import datetime +from typing import Any, cast +from urllib.parse import urlparse + +import caldav +from caldav.lib.error import NotFoundError +from icalendar import Component +from pydantic import SecretStr + +from pronote_sync.config.settings import CalDAVSettings +from pronote_sync.errors import PronoteSyncError +from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE +from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url + +logger = logging.getLogger(__name__) + + +class CalDAVGateway: + """Passerelle d'accès au calendrier CalDAV, isolant la bibliothèque caldav. + + La passerelle gère la connexion, la résolution du calendrier de + destination, la récupération des événements portant le marqueur de + gestion (:data:`MANAGED_PROPERTY`), leur écriture et leur suppression. + Seuls les paramètres non sensibles nécessaires (``calendar_path``, + ``username``) ainsi que l'URL rédigée sont mémorisés sur l'instance ; le + mot de passe et l'URL brute ne sont jamais conservés en clair. + + L'usage typique se fait via le gestionnaire de contexte :: + + with CalDAVGateway(settings) as gateway: + gateway.upsert_event(vcalendar_text, uid) + """ + + def __init__( + self, + settings: CalDAVSettings, + client_factory: Callable[..., Any] | None = None, + ) -> None: + """Initialise la passerelle avec la configuration CalDAV. + + Extrait uniquement les paramètres non sensibles nécessaires + (``calendar_path``, ``username``) ainsi que l'URL rédigée pour la + journalisation. Le mot de passe reste encapsulé dans son + ``SecretStr`` et n'est jamais stocké en clair sur l'instance. + + :param settings: Configuration CalDAV (url, username, password, + calendar_path). + :param client_factory: Appelable optionnel créant une instance + ``DAVClient`` (permet l'injection de dépendances en test). Si + ``None``, utilise ``caldav.DAVClient``. + """ + # ``cast`` nécessaire : mypy ne résout pas le ré-export du module + # ``caldav`` (le type de ``caldav.DAVClient`` est vu comme ``object``). + self._client_factory: Callable[..., Any] = ( + client_factory + if client_factory is not None + else cast(Callable[..., Any], caldav.DAVClient) + ) + self._calendar_path: str = settings.calendar_path + self._redacted_url: str | None = ( + redact_url(settings.url.get_secret_value()) if settings.url else None + ) + self._username: str | None = settings.username + self._url_secret: SecretStr | None = settings.url + self._password_secret: SecretStr | None = settings.password + self._client: Any = None + self._calendar: Any = None + + def _resolve_calendar(self) -> Any: + """Résout le calendrier cible via la découverte CalDAV. + + Interroge le principal CalDAV puis sa liste de calendriers, et + sélectionne celui dont le chemin d'URL correspond au + ``calendar_path`` configuré à la frontière d'un composant de + chemin (barres obliques finales ignorées, préfixe ``/`` garanti par + la normalisation). + + :return: Le calendrier CalDAV correspondant au chemin configuré. + :rtype: Any + :raises PronoteSyncError: Si aucun calendrier ne correspond ou si + plusieurs calendriers correspondent au chemin configuré. + """ + principal = self._client.principal() + calendars = principal.calendars() + normalized_path = self._calendar_path.strip("/") + matches: list[Any] = [] + for cal in calendars: + cal_url = str(cal.url) if hasattr(cal, "url") and cal.url else "" + cal_path = urlparse(cal_url).path.strip("/") + if cal_path == normalized_path or cal_path.endswith(f"/{normalized_path}"): + matches.append(cal) + if len(matches) == 0: + raise PronoteSyncError( + f"Calendrier CalDAV introuvable : {self._redacted_url}" + ) from None + if len(matches) > 1: + raise PronoteSyncError( + f"Calendrier CalDAV ambigu : plusieurs calendriers " + f"correspondent à '{self._calendar_path}'" + ) from None + return matches[0] + + def connect(self) -> None: + """Établit la connexion au serveur CalDAV et résout le calendrier. + + Le mot de passe est extrait de son ``SecretStr`` uniquement pour la + création du ``DAVClient``, en variable locale, puis abandonné. Le + calendrier cible est résolu par découverte + (:meth:`_resolve_calendar`) plutôt que par concaténation d'URL. Toute + exception de la bibliothèque ``caldav`` est interceptée, journalisée + avec :func:`redact_exception` et re-levée en + :class:`PronoteSyncError` — hors du bloc ``except``, afin que + ``__context__`` ne retienne aucune exception brute — sans chaînage ni + donnée sensible. Les erreurs de résolution du calendrier + (message « introuvable » ou « ambigu ») sont propagées telles quelles. + + :raises PronoteSyncError: Si la configuration est incomplète ou si la + connexion au serveur CalDAV échoue. + """ + if self._url_secret is None or self._username is None or self._password_secret is None: + raise PronoteSyncError( + "Configuration CalDAV incomplète : url, username et password sont requis" + ) from None + raw_url = self._url_secret.get_secret_value() + password = self._password_secret.get_secret_value() + error_msg: str | None = None + try: + self._client = self._client_factory( + url=raw_url, username=self._username, password=password + ) + self._calendar = self._resolve_calendar() + except PronoteSyncError: + raise + except Exception as exc: + error_msg = redact_exception(exc) + logger.error("Échec de la connexion CalDAV : %s", error_msg) + if error_msg is not None: + raise PronoteSyncError(f"Échec de la connexion CalDAV : {self._redacted_url}") from None + + def list_managed_events(self, start: datetime, end: datetime) -> list[tuple[str, Any]]: + """Liste les événements gérés par l'outil dans la fenêtre donnée. + + Interroge le serveur CalDAV sur la fenêtre ``[start, end]`` et ne + conserve que les VEVENT portant le marqueur de gestion + (:data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE`). + + :param start: Début de la fenêtre de recherche. + :param end: Fin de la fenêtre de recherche. + :return: Couples ``(uid, vevent)`` pour chaque événement géré trouvé. + :rtype: list[tuple[str, Any]] + :raises PronoteSyncError: Si la passerelle n'est pas connectée ou si + la récupération échoue. + """ + if self._calendar is None: + raise PronoteSyncError("Passerelle CalDAV non connectée") from None + result: list[tuple[str, Any]] = [] + error_msg: str | None = None + try: + events = self._calendar.date_search(start=start, end=end, expand=True) + for event in events: + component: Component = event.icalendar_component + for vevent in component.walk("VEVENT"): + managed = vevent.get(MANAGED_PROPERTY) + if managed is not None and str(managed) == MANAGED_VALUE: + uid = str(vevent.get("UID")) + result.append((uid, vevent)) + except Exception as exc: + error_msg = redact_exception(exc) + logger.error("Échec de la récupération des événements CalDAV : %s", error_msg) + if error_msg is not None: + raise PronoteSyncError( + f"Échec de la récupération des événements CalDAV : {self._redacted_url}" + ) from None + return result + + def upsert_event(self, vcalendar_text: str, uid: str) -> None: + """Crée ou met à jour un événement CalDAV identifié par son UID. + + Recherche d'abord l'événement existant par UID via + ``get_event_by_uid`` : s'il existe, son contenu est remplacé puis + sauvegardé ; s'il est introuvable (``NotFoundError``), un nouvel + événement est créé via ``add_event``. Toute autre exception est + journalisée avec :func:`redact_exception` puis re-levée en + :class:`PronoteSyncError` — hors du bloc ``except``, afin que + ``__context__`` ne retienne aucune exception brute — sans chaînage ni + donnée sensible. + + :param vcalendar_text: Document iCalendar complet (VCALENDAR + VEVENT). + :param uid: UID stable de l'événement à créer ou mettre à jour. + :raises PronoteSyncError: Si la passerelle n'est pas connectée ou si + l'opération échoue. + """ + if self._calendar is None: + raise PronoteSyncError("Passerelle CalDAV non connectée") from None + error_msg: str | None = None + try: + try: + event = self._calendar.get_event_by_uid(uid) + except NotFoundError: + self._calendar.add_event(ical=vcalendar_text) + else: + event.data = vcalendar_text + event.save() + except Exception as exc: + error_msg = redact_exception(exc) + logger.error( + "Échec de l'écriture d'un événement CalDAV (uid=%s) : %s", + redact_secrets(uid), + error_msg, + ) + if error_msg is not None: + raise PronoteSyncError( + f"Échec de l'écriture d'un événement CalDAV : {self._redacted_url}" + ) from None + + def delete_event(self, uid: str) -> None: + """Supprime un événement du calendrier, identifié par son UID. + + Récupère l'événement distant via ``get_event_by_uid`` puis le + supprime. Toute exception est journalisée avec + :func:`redact_exception` et re-levée en :class:`PronoteSyncError` — + hors du bloc ``except``, afin que ``__context__`` ne retienne aucune + exception brute — sans chaînage ni donnée sensible. + + :param uid: Identifiant UID de l'événement à supprimer. + :raises PronoteSyncError: Si la passerelle n'est pas connectée ou si + la suppression échoue. + """ + if self._calendar is None: + raise PronoteSyncError("Passerelle CalDAV non connectée") from None + error_msg: str | None = None + try: + event = self._calendar.get_event_by_uid(uid) + event.delete() + except Exception as exc: + error_msg = redact_exception(exc) + logger.error( + "Échec de la suppression d'un événement CalDAV (uid=%s) : %s", + redact_secrets(uid), + error_msg, + ) + if error_msg is not None: + raise PronoteSyncError( + f"Échec de la suppression d'un événement CalDAV : {self._redacted_url}" + ) from None + + def close(self) -> None: + """Libère les ressources : client, calendrier et secrets. + + Réinitialise le client, le calendrier et les ``SecretStr`` conservés + afin de ne laisser aucune référence à des données sensibles sur + l'instance. + """ + self._client = None + self._calendar = None + self._url_secret = None + self._password_secret = None + + def __enter__(self) -> CalDAVGateway: + """Entre dans le contexte en établissant la connexion. + + :return: La passerelle connectée. + :rtype: CalDAVGateway + :raises PronoteSyncError: Si la connexion échoue. + """ + self.connect() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Quitte le contexte en libérant les ressources. + + Les exceptions éventuellement en cours ne sont pas interceptées et + continuent leur propagation normale. + + :param exc_type: Type de l'exception en cours, le cas échéant. + :param exc_val: Instance de l'exception en cours, le cas échéant. + :param exc_tb: Traceback de l'exception en cours, le cas échéant. + """ + self.close() diff --git a/pronote_sync/sync/executor.py b/pronote_sync/sync/executor.py new file mode 100644 index 0000000..4c0b6c7 --- /dev/null +++ b/pronote_sync/sync/executor.py @@ -0,0 +1,185 @@ +"""Exécuteur du plan de synchronisation CalDAV. + +Ce module fournit :class:`CalDAVSyncExecutor`, qui applique un +:class:`~pronote_sync.models.sync.CalDAVSyncPlan` contre une +:class:`~pronote_sync.sync.caldav.CalDAVGateway` et produit un +:class:`~pronote_sync.models.sync.CalDAVSyncResult` avec des compteurs et +les éventuelles erreurs expurgées. Chaque opération (ajout, mise à jour, +suppression) est indépendante : l'échec d'un événement n'interrompt pas le +lot. En mode ``dry_run``, aucune écriture n'est envoyée à la passerelle, +mais le résultat reflète les opérations qui auraient été effectuées. +""" + +from __future__ import annotations + +import logging + +from pronote_sync.errors import PronoteSyncError +from pronote_sync.models.agenda import Lesson, SchoolEvent +from pronote_sync.models.homework import Homework +from pronote_sync.models.sync import ( + CalDAVSyncPlan, + CalDAVSyncResult, + CalDAVSyncStatus, +) +from pronote_sync.sync.caldav import CalDAVGateway +from pronote_sync.sync.serialization import model_to_vcalendar_text +from pronote_sync.utils.redaction import redact_secrets + +logger = logging.getLogger(__name__) + + +def _model_uid(model: Lesson | Homework | SchoolEvent) -> str: + """Retourne l'UID iCalendar correspondant à un modèle Pronote. + + L'UID reproduit la convention de :mod:`pronote_sync.sync.serialization` + (préfixes ``homework-`` et ``school-event-``) afin de journaliser des + identifiants stables, identiques à ceux envoyés à la passerelle. + + :param model: Modèle Pronote concerné. + :return: UID iCalendar du modèle. + :rtype: str + """ + if isinstance(model, Lesson): + return str(model.id) + if isinstance(model, Homework): + return f"homework-{model.id}" + return f"school-event-{model.label}-{model.from_date.isoformat()}" + + +class CalDAVSyncExecutor: + """Exécute un plan de synchronisation CalDAV contre une passerelle distante. + + Les opérations du plan sont traitées une à une, indépendamment : une + erreur ``PronoteSyncError`` sur un événement est consignée dans le + résultat (message expurgé) sans interrompre le traitement du lot. En + mode ``dry_run``, la passerelle n'est jamais appelée en écriture ; + les compteurs du résultat reflètent néanmoins ce qui aurait été fait. + """ + + def __init__(self, gateway: CalDAVGateway, dry_run: bool = False) -> None: + """Initialise l'exécuteur avec la passerelle CalDAV. + + :param gateway: Passerelle CalDAV connectée. + :param dry_run: Si ``True``, aucune écriture n'est effectuée ; seuls + les logs et le résultat sont renseignés. + """ + self._gateway = gateway + self._dry_run = dry_run + + def execute(self, plan: CalDAVSyncPlan) -> CalDAVSyncResult: + """Exécute le plan de synchronisation et retourne le résultat. + + Les cours, devoirs et événements scolaires sont traités dans l'ordre + « ajouts, mises à jour, suppressions ». Une erreur + ``PronoteSyncError`` sur une opération est consignée dans + ``result.errors`` (message expurgé) sans stopper les autres + opérations ; toute autre exception (erreur de programmation) se + propage. Le statut final vaut ``FAILED`` si au moins une erreur a été + consignée, ``SKIPPED`` si aucune opération n'était à effectuer + (reprise idempotente), sinon ``SUCCESS``. + + :param plan: Plan de synchronisation à appliquer. + :return: Résultat de la synchronisation (statut, compteurs, erreurs). + :rtype: CalDAVSyncResult + """ + result = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0) + + lesson: Lesson + for lesson in plan.lessons_to_add: + self._do_save(lesson, result, is_update=False) + for lesson in plan.lessons_to_update: + self._do_save(lesson, result, is_update=True) + for uid in plan.lessons_to_remove: + self._do_delete(uid, result) + + homework: Homework + for homework in plan.homeworks_to_add: + self._do_save(homework, result, is_update=False) + for homework in plan.homeworks_to_update: + self._do_save(homework, result, is_update=True) + for uid in plan.homeworks_to_remove: + self._do_delete(uid, result) + + school_event: SchoolEvent + for school_event in plan.school_events_to_add: + self._do_save(school_event, result, is_update=False) + for school_event in plan.school_events_to_update: + self._do_save(school_event, result, is_update=True) + for uid in plan.school_events_to_remove: + self._do_delete(uid, result) + + total = result.added + result.updated + result.removed + if result.errors: + result.status = CalDAVSyncStatus.FAILED + elif total == 0: + result.status = CalDAVSyncStatus.SKIPPED + else: + result.status = CalDAVSyncStatus.SUCCESS + return result + + def _do_save( + self, + model: Lesson | Homework | SchoolEvent, + result: CalDAVSyncResult, + is_update: bool, + ) -> None: + """Écrit un événement sur la passerelle, ou simule l'écriture. + + En mode ``dry_run``, l'action est uniquement journalisée et le + compteur correspondant est incrémenté. Sinon, le modèle est + sérialisé en document iCalendar complet (``VCALENDAR``) via + :func:`model_to_vcalendar_text`, puis envoyé à la passerelle avec + l'UID dérivé via :func:`_model_uid` (l'upsert par UID permet la + création ou la mise à jour de l'événement) ; en cas d'erreur + ``PronoteSyncError``, le message expurgé est ajouté à + ``result.errors``. + + :param model: Modèle Pronote à écrire (Lesson, Homework ou SchoolEvent). + :param result: Résultat à mettre à jour (compteurs et erreurs). + :param is_update: Si ``True``, l'opération est une mise à jour, + sinon un ajout. + """ + action = "mise à jour" if is_update else "ajout" + uid = _model_uid(model) + if self._dry_run: + logger.info("DRY-RUN: %s de l'événement UID=%s", action, redact_secrets(uid)) + if is_update: + result.updated += 1 + else: + result.added += 1 + return + try: + vcalendar_text = model_to_vcalendar_text(model) + self._gateway.upsert_event(vcalendar_text, uid) + except PronoteSyncError as exc: + result.errors.append(redact_secrets(str(exc))) + return + logger.info("%s de l'événement UID=%s", action, redact_secrets(uid)) + if is_update: + result.updated += 1 + else: + result.added += 1 + + def _do_delete(self, uid: str, result: CalDAVSyncResult) -> None: + """Supprime un événement de la passerelle, ou simule la suppression. + + En mode ``dry_run``, l'action est uniquement journalisée et le + compteur des suppressions est incrémenté. Sinon, la passerelle est + appelée avec l'UID ; en cas d'erreur ``PronoteSyncError``, le + message expurgé est ajouté à ``result.errors``. + + :param uid: Identifiant UID de l'événement à supprimer. + :param result: Résultat à mettre à jour (compteurs et erreurs). + """ + if self._dry_run: + logger.info("DRY-RUN: suppression de l'événement UID=%s", redact_secrets(uid)) + result.removed += 1 + return + try: + self._gateway.delete_event(uid) + except PronoteSyncError as exc: + result.errors.append(redact_secrets(str(exc))) + return + logger.info("suppression de l'événement UID=%s", redact_secrets(uid)) + result.removed += 1 diff --git a/pronote_sync/sync/planner.py b/pronote_sync/sync/planner.py new file mode 100644 index 0000000..1d56dd8 --- /dev/null +++ b/pronote_sync/sync/planner.py @@ -0,0 +1,112 @@ +"""Planification de la synchronisation CalDAV. + +Ce module compare les données Pronote normalisées aux événements distants +marqués comme gérés par ``pronote-sync`` et produit un plan de synchronisation +CalDAV (ajouts, mises à jour, suppressions) pour chaque catégorie d'événement : +cours, devoirs et événements scolaires. + +Le plan est calculé de manière pure et déterministe : deux entrées identiques +produisent un plan identique, et un événement dont la signature sémantique +n'a pas changé n'apparaît dans aucune liste du plan (idempotence). +""" + +from __future__ import annotations + +from typing import Any + +from pronote_sync.models.agenda import Lesson, SchoolEvent +from pronote_sync.models.homework import Homework +from pronote_sync.models.pronote import PronoteData +from pronote_sync.models.sync import CalDAVSyncPlan +from pronote_sync.sync.serialization import ( + component_to_signature, + homework_to_vevent, + lesson_to_vevent, + school_event_to_vevent, +) + + +def compute_plan( + pronote_data: PronoteData, + remote_managed: list[tuple[str, Any]], +) -> CalDAVSyncPlan: + """Calcule le plan de synchronisation CalDAV. + + :param pronote_data: Données Pronote normalisées (cours, devoirs, événements). + :param remote_managed: Liste de couples (uid, vevent) pour les événements + distants marqués comme gérés par pronote-sync. + :return: Plan de synchronisation avec les listes d'ajouts, mises à jour et + suppressions pour chaque type d'événement. + :rtype: CalDAVSyncPlan + """ + remote_signatures: dict[str, str] = {} + for uid, vevent in remote_managed: + remote_signatures[uid] = component_to_signature(vevent) + + lessons_to_add: list[Lesson] = [] + lessons_to_update: list[Lesson] = [] + lessons_to_remove: list[str] = [] + + local_lessons_by_uid: dict[str, Lesson] = {lesson.id: lesson for lesson in pronote_data.lessons} + for lesson in pronote_data.lessons: + local_sig = component_to_signature(lesson_to_vevent(lesson)) + if lesson.id not in remote_signatures: + lessons_to_add.append(lesson) + elif remote_signatures[lesson.id] != local_sig: + lessons_to_update.append(lesson) + for uid in remote_signatures: + if ( + uid not in local_lessons_by_uid + and not uid.startswith("homework-") + and not uid.startswith("school-event-") + ): + lessons_to_remove.append(uid) + + homeworks_to_add: list[Homework] = [] + homeworks_to_update: list[Homework] = [] + homeworks_to_remove: list[str] = [] + + local_homeworks_by_uid: dict[str, Homework] = { + f"homework-{homework.id}": homework for homework in pronote_data.homeworks + } + for homework in pronote_data.homeworks: + uid = f"homework-{homework.id}" + local_sig = component_to_signature(homework_to_vevent(homework)) + if uid not in remote_signatures: + homeworks_to_add.append(homework) + elif remote_signatures[uid] != local_sig: + homeworks_to_update.append(homework) + for uid in remote_signatures: + if uid.startswith("homework-") and uid not in local_homeworks_by_uid: + homeworks_to_remove.append(uid) + + school_events_to_add: list[SchoolEvent] = [] + school_events_to_update: list[SchoolEvent] = [] + school_events_to_remove: list[str] = [] + + local_school_events_by_uid: dict[str, SchoolEvent] = { + f"school-event-{event.label}-{event.from_date.isoformat()}": event + for event in pronote_data.school_events + } + for school_event in pronote_data.school_events: + uid = f"school-event-{school_event.label}-{school_event.from_date.isoformat()}" + local_sig = component_to_signature(school_event_to_vevent(school_event)) + if uid not in remote_signatures: + school_events_to_add.append(school_event) + elif remote_signatures[uid] != local_sig: + school_events_to_update.append(school_event) + for uid in remote_signatures: + if uid.startswith("school-event-") and uid not in local_school_events_by_uid: + school_events_to_remove.append(uid) + + return CalDAVSyncPlan( + lessons_to_add=lessons_to_add, + lessons_to_update=lessons_to_update, + lessons_to_remove=lessons_to_remove, + homeworks_to_add=homeworks_to_add, + homeworks_to_update=homeworks_to_update, + homeworks_to_remove=homeworks_to_remove, + school_events_to_add=school_events_to_add, + school_events_to_update=school_events_to_update, + school_events_to_remove=school_events_to_remove, + ) diff --git a/pronote_sync/sync/serialization.py b/pronote_sync/sync/serialization.py new file mode 100644 index 0000000..8fb94b8 --- /dev/null +++ b/pronote_sync/sync/serialization.py @@ -0,0 +1,192 @@ +"""Sérialisation des modèles Pronote en composants iCalendar (VEVENT). + +Ce module convertit les modèles métier (:class:`Lesson`, :class:`Homework`, +:class:`SchoolEvent`) en composants :class:`icalendar.Event` destinés à la +synchronisation CalDAV, fournit un enveloppement en document ``VCALENDAR`` +complet (avec ``VERSION`` et ``PRODID``), et extrait une signature sémantique +déterministe d'un composant distant pour permettre une comparaison +idempotente. + +Les composants produits portent le marqueur :data:`MANAGED_PROPERTY` avec la +valeur :data:`MANAGED_VALUE` afin d'identifier les événements gérés par +l'outil et de ne jamais toucher aux événements étrangers du calendrier. +""" + +from __future__ import annotations + +from datetime import datetime, time +from typing import cast + +from icalendar import Calendar, Component, Event, vDate, vDatetime + +from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent +from pronote_sync.models.homework import Homework + +#: Propriété iCalendar marquant un événement géré par ``pronote-sync``. +MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED" + +#: Valeur du marqueur de gestion (version du format de signature). +MANAGED_VALUE = "v1" + +#: Identifiant du produit pour la propriété ``PRODID`` des documents CalDAV. +PRODID = "-//pronote-sync//NONSGML v1.0//EN" + +#: Propriétés prises en compte dans la signature sémantique d'un composant. +_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION") + + +def lesson_to_vevent(lesson: Lesson) -> Event: + """Convertit un cours Pronote en composant VEVENT iCalendar. + + La description contient une ligne par champ renseigné (matière, + professeur(s), salle(s), contenu). Un cours annulé est marqué + ``STATUS:CANCELLED`` et classé dans la catégorie « Annulé », un cours + déplacé dans la catégorie « Déplacé ». + + :param lesson: Cours Pronote à sérialiser. + :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. + :rtype: icalendar.Event + """ + event = Event() + event.add("uid", lesson.id) + event.add("summary", lesson.subject) + event.add("dtstart", vDatetime(lesson.start)) + event.add("dtend", vDatetime(lesson.end)) + + parts: list[str] = [] + if lesson.subject: + parts.append(f"Matière: {lesson.subject}") + if lesson.teachers: + parts.append(f"Professeur(s): {', '.join(lesson.teachers)}") + if lesson.rooms: + parts.append(f"Salle(s): {', '.join(lesson.rooms)}") + if lesson.content: + parts.append(f"Contenu: {lesson.content}") + event.add("description", "\n".join(parts)) + + if lesson.status == LessonStatus.CANCELLED: + event.add("status", "CANCELLED") + else: + event.add("status", "CONFIRMED") + + categories = ["Pronote"] + if lesson.status == LessonStatus.CANCELLED: + categories.append("Annulé") + elif lesson.status == LessonStatus.MOVED: + categories.append("Déplacé") + event.add("categories", categories) + + event.add(MANAGED_PROPERTY, MANAGED_VALUE) + return event + + +def homework_to_vevent(homework: Homework) -> Event: + """Convertit un devoir Pronote en composant VEVENT iCalendar. + + Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la + journée d'échéance, entre 08:00 et 18:00. + + :param homework: Devoir Pronote à sérialiser. + :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. + :rtype: icalendar.Event + """ + event = Event() + event.add("uid", f"homework-{homework.id}") + event.add("summary", f"Devoir: {homework.subject}") + event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0)))) + event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0)))) + event.add("description", homework.text) + event.add("status", "NEEDS-ACTION") + event.add("categories", ["Pronote", "Devoir"]) + event.add(MANAGED_PROPERTY, MANAGED_VALUE) + return event + + +def school_event_to_vevent(school_event: SchoolEvent) -> Event: + """Convertit un événement scolaire en composant VEVENT iCalendar. + + :param school_event: Événement scolaire (vacances, jour férié) à sérialiser. + :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. + :rtype: icalendar.Event + """ + event = Event() + event.add("uid", f"school-event-{school_event.label}-{school_event.from_date.isoformat()}") + event.add("summary", school_event.label) + event.add("dtstart", vDate(school_event.from_date)) + event.add("dtend", vDate(school_event.to_date)) + event.add("status", "CONFIRMED") + event.add("categories", ["Pronote", school_event.kind.value]) + event.add(MANAGED_PROPERTY, MANAGED_VALUE) + return event + + +def model_to_vcalendar_text(model: Lesson | Homework | SchoolEvent) -> str: + """Sérialise un modèle Pronote en document iCalendar complet (VCALENDAR). + + Produit un document ``VCALENDAR`` valide contenant un seul ``VEVENT``, + avec les propriétés ``VERSION:2.0`` et ``PRODID`` requises par le protocole + CalDAV. + + :param model: Modèle Pronote à sérialiser (Lesson, Homework ou SchoolEvent). + :return: Document iCalendar complet en texte. + :rtype: str + :raises ValueError: Si le type de modèle n'est pas supporté. + """ + if isinstance(model, Lesson): + vevent = lesson_to_vevent(model) + elif isinstance(model, Homework): + vevent = homework_to_vevent(model) + elif isinstance(model, SchoolEvent): + vevent = school_event_to_vevent(model) + else: + raise ValueError(f"Type de modèle non supporté : {type(model).__name__}") + + cal = Calendar() + cal.add("prodid", PRODID) + cal.add("version", "2.0") + cal.add_component(vevent) + # ``to_ical()`` n'est pas typé dans icalendar : le cast documente le + # décodage UTF-8 en texte et satisfait mypy strict. + return cast(str, cal.to_ical().decode("utf-8")) + + +def component_to_signature(component: Component) -> str: + """Extrait une signature sémantique déterministe d'un composant iCalendar. + + La signature couvre l'UID, le résumé, les dates de début et de fin, le + statut, la description, les catégories (triées) et le marqueur de gestion. + Les propriétés volatiles (``DTSTAMP``, ``CREATED``, ``LAST-MODIFIED``, + ``SEQUENCE``) sont volontairement exclues : elles changent à chaque + écriture serveur et ne reflètent aucun changement des données Pronote. + + Les valeurs textuelles sont normalisées (espaces rognés, minuscules) et + les dates/heures sérialisées via ``isoformat()``, de sorte que deux + composants au contenu sémantiquement identique produisent la même + signature. + + :param component: Composant iCalendar (généralement un VEVENT distant). + :return: Paires ``clé=valeur`` triées et jointes par ``|``. + :rtype: str + """ + props: list[str] = [] + for key in _SIGNATURE_KEYS: + raw = component.get(key) + if raw is None: + continue + value = getattr(raw, "dt", raw) + if hasattr(value, "isoformat"): + rendered = value.isoformat() + else: + rendered = str(value).strip().lower() + props.append(f"{key.lower()}={rendered}") + + categories = component.get("CATEGORIES") + if categories is not None: + cats = sorted(str(c).strip().lower() for c in categories.cats) + props.append(f"categories={','.join(cats)}") + + managed = component.get(MANAGED_PROPERTY) + if managed is not None: + props.append(f"managed={str(managed).strip().lower()}") + + return "|".join(sorted(props)) diff --git a/pronote_sync/sync/synchronizer.py b/pronote_sync/sync/synchronizer.py new file mode 100644 index 0000000..040b69d --- /dev/null +++ b/pronote_sync/sync/synchronizer.py @@ -0,0 +1,107 @@ +"""Orchestrateur de la synchronisation CalDAV. + +Ce module fournit :func:`synchronize`, le point d'entrée haut niveau qui +enchaîne les trois phases de la synchronisation : connexion à la passerelle +CalDAV, scan des événements distants gérés et calcul du plan, puis exécution +du plan (ou simulation en mode ``dry_run``). Il s'appuie sur +:class:`~pronote_sync.sync.caldav.CalDAVGateway`, +:func:`~pronote_sync.sync.planner.compute_plan` et +:class:`~pronote_sync.sync.executor.CalDAVSyncExecutor`. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Any + +from pronote_sync.config.settings import Settings +from pronote_sync.errors import PronoteSyncError +from pronote_sync.models.pronote import PronoteData +from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus +from pronote_sync.sync.caldav import CalDAVGateway +from pronote_sync.sync.executor import CalDAVSyncExecutor +from pronote_sync.sync.planner import compute_plan + +logger = logging.getLogger(__name__) + + +def synchronize( + pronote_data: PronoteData, + settings: Settings, + client_factory: Callable[..., Any] | None = None, +) -> CalDAVSyncResult: + """Synchronise les données Pronote vers le calendrier CalDAV. + + Enchaîne les trois phases : connexion à la passerelle, scan distant et + calcul du plan, puis exécution (ou simulation dry-run). + + :param pronote_data: Données Pronote normalisées à synchroniser. + :param settings: Configuration racine du pipeline. + :param client_factory: Fabrique optionnelle de client DAV (pour les tests). + :return: Résultat de la synchronisation (statut, compteurs, erreurs). + :rtype: CalDAVSyncResult + :raises PronoteSyncError: Si la configuration CalDAV est incomplète ou si la + connexion échoue. + """ + # Fenêtre temporelle fondée sur l'instant courant. Aucune abstraction + # d'horloge n'existe encore dans le dépôt (les données Pronote sont par + # convention naïves en heure locale) : ``datetime.now()`` est utilisé ici, + # point d'entrée de l'orchestration, et pourrait être refactoré plus tard + # vers une horloge injectable sans changer le contrat. + now = datetime.now() + start = now - timedelta(days=settings.app.sync_past_days) + end = now + timedelta(days=settings.app.sync_future_days) + + if ( + settings.caldav.url is None + or settings.caldav.username is None + or settings.caldav.password is None + ): + logger.info("CalDAV non configuré — synchronisation ignorée") + return CalDAVSyncResult(status=CalDAVSyncStatus.SKIPPED, added=0, updated=0, removed=0) + + gateway = CalDAVGateway(settings.caldav, client_factory=client_factory) + try: + with gateway: + remote_managed = gateway.list_managed_events(start=start, end=end) + logger.info( + "Synchronisation CalDAV : %d événements distants gérés trouvés", + len(remote_managed), + ) + + plan = compute_plan(pronote_data, remote_managed) + n_add = ( + len(plan.lessons_to_add) + + len(plan.homeworks_to_add) + + len(plan.school_events_to_add) + ) + n_update = ( + len(plan.lessons_to_update) + + len(plan.homeworks_to_update) + + len(plan.school_events_to_update) + ) + n_remove = ( + len(plan.lessons_to_remove) + + len(plan.homeworks_to_remove) + + len(plan.school_events_to_remove) + ) + logger.info( + "Plan : %d ajouts, %d mises à jour, %d suppressions", + n_add, + n_update, + n_remove, + ) + + if settings.app.dry_run: + logger.info("DRY-RUN : aucune écriture ne sera effectuée sur le calendrier") + executor = CalDAVSyncExecutor(gateway, dry_run=settings.app.dry_run) + return executor.execute(plan) + except PronoteSyncError: + logger.error("Échec de la synchronisation CalDAV") + # Re-lève la même exception de domaine sans en créer de nouvelle. + # ``PronoteSyncError`` a déjà été levée avec ``from None`` en amont + # (passerelle CalDAV), donc ``__cause__`` et ``__context__`` restent + # propres : un ``raise`` nu préserve cet état sans ajouter de chaînage. + raise diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..8917355 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,99 @@ +"""Fixtures partagées pour les tests de pronote-sync.""" + +from __future__ import annotations + +from datetime import date, datetime + +import pytest +from pydantic import SecretStr + +from pronote_sync.config.settings import CalDAVSettings +from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind +from pronote_sync.models.homework import Homework +from pronote_sync.models.pronote import PronoteData + + +@pytest.fixture +def sample_lesson() -> Lesson: + """Cours normal pour les tests.""" + return Lesson( + id="L-1234-Normal", + 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, + ) + + +@pytest.fixture +def sample_cancelled_lesson() -> Lesson: + """Cours annulé pour les tests.""" + return Lesson( + id="L-5678-Cancelled", + start=datetime(2026, 1, 16, 10, 0), + end=datetime(2026, 1, 16, 11, 0), + subject="Français", + teachers=("Prof Martin",), + rooms=("Salle 202",), + status=LessonStatus.CANCELLED, + group=None, + content=None, + ) + + +@pytest.fixture +def sample_homework() -> Homework: + """Devoir pour les tests.""" + return Homework( + id="hw-001", + subject="Histoire", + teachers=("Prof Bernard",), + assigned_on=date(2026, 1, 15), + due_on=date(2026, 1, 20), + text="Lire le chapitre 5", + html="

Lire le chapitre 5

", + ) + + +@pytest.fixture +def sample_school_event() -> SchoolEvent: + """Événement scolaire pour les tests.""" + return SchoolEvent( + kind=SchoolEventKind.HOLIDAY, + label="Vacances de Noël", + from_date=date(2026, 12, 20), + to_date=date(2027, 1, 5), + ) + + +@pytest.fixture +def caldav_settings() -> CalDAVSettings: + """Configuration CalDAV de test avec une URL HTTPS.""" + return CalDAVSettings( + url=SecretStr("https://caldav.example.com/remote.php/dav/"), + username="test-user", + password=SecretStr("test-secret-password-12345"), + calendar_path="/pronote-sync/", + ) + + +@pytest.fixture +def pronote_data( + sample_lesson: Lesson, + sample_cancelled_lesson: Lesson, + sample_homework: Homework, + sample_school_event: SchoolEvent, +) -> PronoteData: + """Données Pronote de test avec des cours, devoirs et événements.""" + return PronoteData( + lessons=[sample_lesson, sample_cancelled_lesson], + homeworks=[sample_homework], + school_events=[sample_school_event], + messages=[], + target_date=date(2026, 1, 15), + generated_at=datetime(2026, 1, 15, 0, 0), + ) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_caldav_sync.py b/tests/integration/test_caldav_sync.py new file mode 100644 index 0000000..058d0bd --- /dev/null +++ b/tests/integration/test_caldav_sync.py @@ -0,0 +1,772 @@ +"""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 date_search( + self, start: datetime, end: datetime, expand: bool = True + ) -> list[FakeCalendarEvent]: + """Retourne tous les événements comme de faux objets calendrier.""" + results: list[FakeCalendarEvent] = [] + for uid, ical_text in self._server._events.items(): + results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server)) + 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, + ) + + # 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, + ) + 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, + ) + 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, + ) + + # 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, + ) + + # 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, + ) + + # 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, + ) + + # 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, + ) + + # 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, + ) + + # 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, + ) + + # 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, + ) + + # 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, + ) + + # 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 diff --git a/tests/unit/test_caldav_executor.py b/tests/unit/test_caldav_executor.py new file mode 100644 index 0000000..a2b2070 --- /dev/null +++ b/tests/unit/test_caldav_executor.py @@ -0,0 +1,622 @@ +"""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) + + mock_gateway.upsert_event.assert_called_once() + assert result.updated == 1 + + +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) + + # 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 diff --git a/tests/unit/test_caldav_gateway.py b/tests/unit/test_caldav_gateway.py new file mode 100644 index 0000000..402c5d2 --- /dev/null +++ b/tests/unit/test_caldav_gateway.py @@ -0,0 +1,716 @@ +"""Tests unitaires pour la passerelle CalDAV. + +Ce module vérifie que :class:`CalDAVGateway` gère correctement la connexion, +la liste des événements gérés, l'écriture et la suppression d'événements, +avec une attention particulière à la sécurité (masquage des secrets). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest +from caldav.lib.error import NotFoundError +from icalendar import Calendar, Event +from pydantic import SecretStr + +from pronote_sync.config.settings import CalDAVSettings +from pronote_sync.errors import PronoteSyncError +from pronote_sync.sync.caldav import CalDAVGateway +from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE + +if TYPE_CHECKING: + pass + + +# --- Fixtures --- + + +@pytest.fixture +def caldav_settings() -> CalDAVSettings: + """Fournit des paramètres CalDAV valides pour les tests. + + :return: Instance de CalDAVSettings. + :rtype: CalDAVSettings + """ + return CalDAVSettings( + url=SecretStr("https://caldav.example.com"), + username="testuser", + password=SecretStr("testpass123"), + calendar_path="/pronote-sync/", + ) + + +@pytest.fixture +def caldav_settings_http_localhost() -> CalDAVSettings: + """Fournit des paramètres CalDAV avec HTTP pour localhost. + + :return: Instance de CalDAVSettings. + :rtype: CalDAVSettings + """ + return CalDAVSettings( + url=SecretStr("http://localhost:5232"), + username="testuser", + password=SecretStr("testpass123"), + calendar_path="/pronote-sync/", + allow_insecure_http=True, + ) + + +@pytest.fixture +def mock_client_factory() -> MagicMock: + """Fournit une usine de clients CalDAV mockée. + + Le client retourné expose un principal dont la liste de calendriers + contient un calendrier dont le chemin se termine par ``pronote-sync``, + ce qui permet à :meth:`~pronote_sync.sync.caldav.CalDAVGateway.connect` + de le résoudre par découverte. + + :return: MagicMock configuré pour retourner un client, un principal et + un calendrier mockés. + :rtype: MagicMock + """ + mock_factory = MagicMock() + + # Configurer le mock pour retourner un client + mock_client = MagicMock() + mock_factory.return_value = mock_client + + # Configurer le client pour exposer un principal avec un calendrier + mock_principal = MagicMock() + mock_client.principal.return_value = mock_principal + + mock_calendar = MagicMock() + mock_calendar.url = "https://caldav.example.com/calendars/testuser/pronote-sync/" + mock_principal.calendars.return_value = [mock_calendar] + + return mock_factory + + +@pytest.fixture +def mock_managed_event() -> MagicMock: + """Fournit un événement CalDAV mocké avec le marqueur de gestion. + + :return: MagicMock configuré comme un événement géré. + :rtype: MagicMock + """ + mock_event = MagicMock() + # Créer un VEVENT avec le marqueur de gestion + vevent = Event() + vevent.add("UID", "test-uid-123") + vevent.add("SUMMARY", "Test Event") + vevent.add(MANAGED_PROPERTY, MANAGED_VALUE) + mock_event.icalendar_component = Calendar() + mock_event.icalendar_component.add_component(vevent) + return mock_event + + +@pytest.fixture +def mock_unmanaged_event() -> MagicMock: + """Fournit un événement CalDAV mocké sans le marqueur de gestion. + + :return: MagicMock configuré comme un événement non géré. + :rtype: MagicMock + """ + mock_event = MagicMock() + # Créer un VEVENT sans le marqueur de gestion + vevent = Event() + vevent.add("UID", "unmanaged-uid-456") + vevent.add("SUMMARY", "Unmanaged Event") + mock_event.icalendar_component = Calendar() + mock_event.icalendar_component.add_component(vevent) + return mock_event + + +# --- Connection tests --- + + +def test_connect_success( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que connect() configure le client et résout le calendrier. + + La résolution passe par la découverte CalDAV (``principal()`` puis + ``calendars()``) et non par une concaténation d'URL. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + # Vérifier que l'usine a été appelée avec les bons paramètres + mock_client_factory.assert_called_once() + call_kwargs = mock_client_factory.call_args[1] + assert call_kwargs["url"] == "https://caldav.example.com" + assert call_kwargs["username"] == "testuser" + assert call_kwargs["password"] == "testpass123" + + # Vérifier que le calendrier a été résolu par découverte + mock_client = mock_client_factory.return_value + mock_client.principal.assert_called_once_with() + mock_principal = mock_client.principal.return_value + mock_principal.calendars.assert_called_once_with() + + mock_calendar = mock_principal.calendars.return_value[0] + assert gateway._calendar is mock_calendar + + +def test_connect_calendar_not_found_raises( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que connect() lève PronoteSyncError si aucun calendrier ne correspond. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + mock_principal = mock_client_factory.return_value.principal.return_value + other_calendar = MagicMock() + other_calendar.url = "https://caldav.example.com/calendars/testuser/autre-calendrier/" + mock_principal.calendars.return_value = [other_calendar] + + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.connect() + + assert "introuvable" in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + +def test_connect_calendar_ambiguous_raises( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que connect() lève PronoteSyncError si plusieurs calendriers correspondent. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + mock_principal = mock_client_factory.return_value.principal.return_value + cal1 = MagicMock() + cal1.url = "https://caldav.example.com/calendars/testuser/pronote-sync/" + cal2 = MagicMock() + cal2.url = "https://caldav.example.com/calendars/autreuser/pronote-sync/" + mock_principal.calendars.return_value = [cal1, cal2] + + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.connect() + + assert "ambigu" in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + +def test_connect_calendar_path_boundary_not_matched( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que connect() ne résout pas un chemin partageant un préfixe. + + Un calendrier dont le chemin se termine par ``pronote-sync`` sans + frontière de composant (ex : ``.../not-pronote-sync/``) ne doit pas + correspondre au ``calendar_path`` configuré ``/pronote-sync/``. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + mock_principal = mock_client_factory.return_value.principal.return_value + boundary_calendar = MagicMock() + boundary_calendar.url = "https://caldav.example.com/calendars/testuser/not-pronote-sync/" + mock_principal.calendars.return_value = [boundary_calendar] + + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.connect() + + assert "introuvable" in str(exc_info.value) + + +def test_connect_failure_raises_pronote_sync_error( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que connect() lève PronoteSyncError en cas d'échec de connexion. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + + # Créer une usine qui lève une exception + def failing_factory(*args: object, **kwargs: object) -> None: + raise ConnectionError("Connection failed") + + gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.connect() + + # Vérifier que le message ne contient pas l'URL brute + error_message = str(exc_info.value) + assert "caldav.example.com" in error_message # L'URL rédigée doit être présente + assert "testpass123" not in error_message # Le mot de passe ne doit pas être présent + assert "from None" in str(exc_info.typename) or exc_info.value.__cause__ is None + + +def test_connect_missing_credentials_raises( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que connect() lève PronoteSyncError si les identifiants sont manquants. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + # Créer des paramètres sans URL + incomplete_settings = CalDAVSettings( + url=None, + username=None, + password=None, + calendar_path="/pronote-sync/", + ) + + gateway = CalDAVGateway(incomplete_settings) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.connect() + + assert "incomplète" in str(exc_info.value) + + +# --- list_managed_events tests --- + + +def test_list_managed_events_returns_only_managed( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, + mock_managed_event: MagicMock, + mock_unmanaged_event: MagicMock, +) -> None: + """Vérifie que list_managed_events ne retourne que les événements gérés. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :param mock_managed_event: Événement géré mocké. + :param mock_unmanaged_event: Événement non géré mocké. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + # Configurer le calendrier pour retourner les deux événements + mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ + 0 + ] + mock_calendar.date_search.return_value = [mock_managed_event, mock_unmanaged_event] + + start = datetime(2026, 1, 1) + end = datetime(2026, 12, 31) + result = gateway.list_managed_events(start, end) + + # Seuls les événements gérés doivent être retournés + assert len(result) == 1 + uid, vevent = result[0] + assert uid == "test-uid-123" + assert str(vevent.get("UID")) == "test-uid-123" + + +def test_list_managed_events_not_connected_raises( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que list_managed_events lève PronoteSyncError si non connecté. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + gateway = CalDAVGateway(caldav_settings) + # Ne pas appeler connect() + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31)) + + assert "non connectée" in str(exc_info.value) + + +def test_list_managed_events_caldav_error_raises( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que list_managed_events lève PronoteSyncError en cas d'erreur CalDAV. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + # Configurer le calendrier pour lever une exception + mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ + 0 + ] + mock_calendar.date_search.side_effect = Exception("CalDAV error") + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31)) + + # Vérifier que le message ne contient pas de secret + error_message = str(exc_info.value) + assert "testpass123" not in error_message + assert exc_info.value.__context__ is None + + +# --- upsert_event tests --- + + +def test_upsert_event_creates_new_event_when_uid_missing( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que upsert_event crée un événement si l'UID est introuvable. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ + 0 + ] + mock_calendar.get_event_by_uid.side_effect = NotFoundError("Event not found") + + vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR" + + gateway.upsert_event(vcalendar_text, "test-123") + + mock_calendar.get_event_by_uid.assert_called_once_with("test-123") + mock_calendar.add_event.assert_called_once_with(ical=vcalendar_text) + + +def test_upsert_event_updates_existing_event_by_uid( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que upsert_event remplace le contenu d'un événement existant. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ + 0 + ] + mock_event = MagicMock() + mock_calendar.get_event_by_uid.return_value = mock_event + + vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR" + + gateway.upsert_event(vcalendar_text, "test-123") + + mock_calendar.get_event_by_uid.assert_called_once_with("test-123") + assert mock_event.data == vcalendar_text + mock_event.save.assert_called_once() + mock_calendar.add_event.assert_not_called() + + +def test_upsert_event_not_connected_raises( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que upsert_event lève PronoteSyncError si non connecté. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + gateway = CalDAVGateway(caldav_settings) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-123") + + assert "non connectée" in str(exc_info.value) + + +def test_upsert_event_caldav_error_raises( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que upsert_event lève PronoteSyncError en cas d'erreur CalDAV. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ + 0 + ] + mock_calendar.get_event_by_uid.side_effect = Exception("Save error") + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-123") + + error_message = str(exc_info.value) + assert "testpass123" not in error_message + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + +# --- delete_event tests --- + + +def test_delete_event_calls_calendar( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que delete_event appelle event.delete() sur l'événement trouvé. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + # Configurer le calendrier pour retourner un événement mocké + mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ + 0 + ] + mock_event = MagicMock() + mock_calendar.get_event_by_uid.return_value = mock_event + + gateway.delete_event("test-uid-123") + + mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123") + mock_event.delete.assert_called_once() + + +def test_delete_event_not_connected_raises( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que delete_event lève PronoteSyncError si non connecté. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + gateway = CalDAVGateway(caldav_settings) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.delete_event("test-uid-123") + + assert "non connectée" in str(exc_info.value) + + +def test_delete_event_caldav_error_raises( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que delete_event lève PronoteSyncError en cas d'erreur CalDAV. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ + 0 + ] + mock_calendar.get_event_by_uid.side_effect = Exception("Delete error") + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.delete_event("test-uid-123") + + error_message = str(exc_info.value) + assert "testpass123" not in error_message + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + +# --- Context manager tests --- + + +def test_context_manager_calls_connect_and_close( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que le gestionnaire de contexte appelle connect() et close(). + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + + with gateway: + # À l'intérieur du contexte, le client doit être configuré + assert gateway._client is not None + assert gateway._calendar is not None + + # Après la sortie du contexte, le client doit être réinitialisé + assert gateway._client is None + assert gateway._calendar is None + + +def test_context_manager_connect_failure( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que le gestionnaire de contexte propage l'erreur de connexion. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + + def failing_factory(*args: object, **kwargs: object) -> None: + raise ConnectionError("Connection failed") + + gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory) + + with pytest.raises(PronoteSyncError): + with gateway: + pass # Ne doit pas être atteint + + +# --- Security tests --- + + +def test_no_plaintext_password_in_vars( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que vars(gateway) ne contient pas le mot de passe en clair. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + gateway = CalDAVGateway(caldav_settings) + + gateway_vars = vars(gateway) + + # Vérifier que le mot de passe n'est pas en clair + assert "testpass123" not in str(gateway_vars) + + # Vérifier que l'URL brute avec credentials n'est pas en clair + # Note: _redacted_url contient l'URL sans credentials, ce qui est acceptable + assert "testuser:testpass123@" not in str(gateway_vars) + + +def test_repr_does_not_leak_password( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que repr(gateway) ne fuit pas le mot de passe. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + gateway = CalDAVGateway(caldav_settings) + + repr_str = repr(gateway) + assert "testpass123" not in repr_str + + +def test_str_does_not_leak_password( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que str(gateway) ne fuit pas le mot de passe. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + gateway = CalDAVGateway(caldav_settings) + + str_str = str(gateway) + assert "testpass123" not in str_str + + +def test_redacted_url_in_error_message( + caldav_settings: CalDAVSettings, +) -> None: + """Vérifie que les messages d'erreur contiennent l'URL rédigée, pas l'URL brute. + + :param caldav_settings: Paramètres CalDAV valides. + :return: None + """ + + def failing_factory(*args: object, **kwargs: object) -> None: + raise ConnectionError("Connection failed") + + gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory) + + try: + gateway.connect() + except PronoteSyncError as exc: + error_message = str(exc) + # L'URL doit être rédigée (sans credentials) + assert "caldav.example.com" in error_message + # Le mot de passe ne doit pas être présent + assert "testpass123" not in error_message + # L'URL complète avec credentials ne doit pas être présente + assert "https://testuser:testpass123@caldav.example.com" not in error_message + + +def test_close_clears_secrets( + caldav_settings: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que close() réinitialise les secrets. + + :param caldav_settings: Paramètres CalDAV valides. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) + gateway.connect() + + # Avant close(), les secrets sont présents + assert gateway._url_secret is not None + assert gateway._password_secret is not None + + gateway.close() + + # Après close(), les secrets sont réinitialisés + assert gateway._url_secret is None + assert gateway._password_secret is None + assert gateway._client is None + assert gateway._calendar is None + + +# --- HTTP localhost tests --- + + +def test_http_localhost_allowed_with_flag( + caldav_settings_http_localhost: CalDAVSettings, + mock_client_factory: MagicMock, +) -> None: + """Vérifie que HTTP est autorisé pour localhost avec allow_insecure_http=True. + + :param caldav_settings_http_localhost: Paramètres CalDAV avec HTTP pour localhost. + :param mock_client_factory: Usine de clients mockée. + :return: None + """ + gateway = CalDAVGateway(caldav_settings_http_localhost, client_factory=mock_client_factory) + + # La validation doit réussir (pas d'erreur levée) + # Le client_factory est appelé avec l'URL HTTP + gateway.connect() + + call_kwargs = mock_client_factory.call_args[1] + assert call_kwargs["url"] == "http://localhost:5232" + + +# Ensure trailing newline diff --git a/tests/unit/test_caldav_planner.py b/tests/unit/test_caldav_planner.py new file mode 100644 index 0000000..87d5646 --- /dev/null +++ b/tests/unit/test_caldav_planner.py @@ -0,0 +1,581 @@ +"""Tests unitaires pour le planificateur de synchronisation CalDAV. + +Ce module vérifie que la fonction :func:`compute_plan` produit correctement +les listes d'ajouts, mises à jour et suppressions pour chaque catégorie +(cours, devoirs, événements scolaires) en comparant les données Pronote +normalisées aux événements distants marqués comme gérés. +""" + +from __future__ import annotations + +from datetime import date, datetime + +from icalendar import Event + +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.sync.planner import compute_plan +from pronote_sync.sync.serialization import ( + lesson_to_vevent, +) + +# --- 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, + ) + + +def _make_vevent( + uid: str, + summary: str, + dtstart: datetime, + dtend: datetime, + status: str = "CONFIRMED", + categories: list[str] | None = None, +) -> Event: + """Fabrique un VEVENT iCalendar pour les tests. + + :param uid: UID de l'événement. + :param summary: Résumé. + :param dtstart: Date/heure de début. + :param dtend: Date/heure de fin. + :param status: Statut. + :param categories: Catégories. + :return: Instance de Event. + :rtype: Event + """ + from icalendar import vDatetime + + event = Event() + event.add("UID", uid) + event.add("SUMMARY", summary) + event.add("DTSTART", vDatetime(dtstart)) + event.add("DTEND", vDatetime(dtend)) + event.add("STATUS", status) + if categories: + event.add("CATEGORIES", categories) + # Ajouter le marqueur de gestion + from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE + + event.add(MANAGED_PROPERTY, MANAGED_VALUE) + return event + + +def _make_pronote_data( + lessons: list[Lesson] | None = None, + homeworks: list[Homework] | None = None, + school_events: list[SchoolEvent] | None = None, +) -> PronoteData: + """Fabrique des données Pronote pour les tests. + + :param lessons: Liste des cours. + :param homeworks: Liste des devoirs. + :param school_events: Liste des événements scolaires. + :return: Instance de PronoteData. + :rtype: PronoteData + """ + return PronoteData( + lessons=lessons or [], + homeworks=homeworks or [], + school_events=school_events or [], + messages=[], + target_date=date(2026, 1, 15), + generated_at=datetime(2026, 1, 15, 0, 0), + ) + + +# --- Tests for lessons --- + + +def test_lesson_add_when_not_in_remote() -> None: + """Vérifie qu'un cours non présent à distance va dans lessons_to_add. + + :return: None + """ + lesson = _make_lesson(lesson_id="L-1234") + pronote_data = _make_pronote_data(lessons=[lesson]) + remote_managed: list[tuple[str, Event]] = [] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.lessons_to_add) == 1 + assert plan.lessons_to_add[0].id == "L-1234" + assert len(plan.lessons_to_update) == 0 + assert len(plan.lessons_to_remove) == 0 + + +def test_lesson_update_when_signature_differs() -> None: + """Vérifie qu'un cours présent à distance avec une signature différente va dans lessons_to_update. + + :return: None + """ + lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques") + pronote_data = _make_pronote_data(lessons=[lesson]) + + # Créer un VEVENT distant avec un sujet différent + remote_event = _make_vevent( + uid="L-1234", + summary="Physique", # Différent + dtstart=datetime(2026, 1, 15, 8, 0), + dtend=datetime(2026, 1, 15, 9, 0), + ) + remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.lessons_to_add) == 0 + assert len(plan.lessons_to_update) == 1 + assert plan.lessons_to_update[0].id == "L-1234" + assert len(plan.lessons_to_remove) == 0 + + +def test_lesson_idempotent_when_signature_same() -> None: + """Vérifie qu'un cours présent à distance avec la même signature n'apparaît dans aucune liste. + + :return: None + """ + lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques") + pronote_data = _make_pronote_data(lessons=[lesson]) + + # Créer un VEVENT distant avec les mêmes propriétés + remote_event = lesson_to_vevent(lesson) + remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.lessons_to_add) == 0 + assert len(plan.lessons_to_update) == 0 + assert len(plan.lessons_to_remove) == 0 + + +def test_lesson_remove_when_not_in_local() -> None: + """Vérifie qu'un UID distant non présent en local va dans lessons_to_remove. + + :return: None + """ + pronote_data = _make_pronote_data(lessons=[]) + + remote_event = _make_vevent( + uid="L-9999", + summary="Ancien cours", + dtstart=datetime(2026, 1, 15, 8, 0), + dtend=datetime(2026, 1, 15, 9, 0), + ) + remote_managed: list[tuple[str, Event]] = [("L-9999", remote_event)] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.lessons_to_add) == 0 + assert len(plan.lessons_to_update) == 0 + assert len(plan.lessons_to_remove) == 1 + assert plan.lessons_to_remove[0] == "L-9999" + + +def test_lesson_cancelled_preserved() -> None: + """Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour). + + :return: None + """ + lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED) + pronote_data = _make_pronote_data(lessons=[lesson]) + remote_managed: list[tuple[str, Event]] = [] + + plan = compute_plan(pronote_data, remote_managed) + + # Un cours annulé doit aller dans lessons_to_add comme n'importe quel autre cours + assert len(plan.lessons_to_add) == 1 + assert plan.lessons_to_add[0].id == "L-1234" + assert plan.lessons_to_add[0].status == LessonStatus.CANCELLED + + +# --- Tests for homeworks --- + + +def test_homework_add_when_not_in_remote() -> None: + """Vérifie qu'un devoir non présent à distance va dans homeworks_to_add. + + :return: None + """ + homework = _make_homework(homework_id="HW-5678") + pronote_data = _make_pronote_data(homeworks=[homework]) + remote_managed: list[tuple[str, Event]] = [] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.homeworks_to_add) == 1 + assert plan.homeworks_to_add[0].id == "HW-5678" + assert len(plan.homeworks_to_update) == 0 + assert len(plan.homeworks_to_remove) == 0 + + +def test_homework_update_when_signature_differs() -> None: + """Vérifie qu'un devoir présent à distance avec une signature différente va dans homeworks_to_update. + + :return: None + """ + homework = _make_homework(homework_id="HW-5678", subject="Mathématiques") + pronote_data = _make_pronote_data(homeworks=[homework]) + + # Créer un VEVENT distant avec un sujet différent + remote_event = _make_vevent( + uid="homework-HW-5678", + summary="Devoir: Physique", # Différent + dtstart=datetime(2026, 1, 20, 8, 0), + dtend=datetime(2026, 1, 20, 18, 0), + ) + remote_managed: list[tuple[str, Event]] = [("homework-HW-5678", remote_event)] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.homeworks_to_add) == 0 + assert len(plan.homeworks_to_update) == 1 + assert plan.homeworks_to_update[0].id == "HW-5678" + assert len(plan.homeworks_to_remove) == 0 + + +def test_homework_remove_when_not_in_local() -> None: + """Vérifie qu'un UID de devoir distant non présent en local va dans homeworks_to_remove. + + :return: None + """ + pronote_data = _make_pronote_data(homeworks=[]) + + remote_event = _make_vevent( + uid="homework-HW-9999", + summary="Devoir: Ancien devoir", + dtstart=datetime(2026, 1, 20, 8, 0), + dtend=datetime(2026, 1, 20, 18, 0), + ) + remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.homeworks_to_add) == 0 + assert len(plan.homeworks_to_update) == 0 + assert len(plan.homeworks_to_remove) == 1 + assert plan.homeworks_to_remove[0] == "homework-HW-9999" + + +# --- Tests for school events --- + + +def test_school_event_add_when_not_in_remote() -> None: + """Vérifie qu'un événement scolaire non présent à distance va dans school_events_to_add. + + :return: None + """ + school_event = _make_school_event( + label="Vacances de Noël", + from_date=date(2026, 12, 20), + ) + pronote_data = _make_pronote_data(school_events=[school_event]) + remote_managed: list[tuple[str, Event]] = [] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.school_events_to_add) == 1 + assert plan.school_events_to_add[0].label == "Vacances de Noël" + assert len(plan.school_events_to_update) == 0 + assert len(plan.school_events_to_remove) == 0 + + +def test_school_event_update_when_signature_differs() -> None: + """Vérifie qu'un événement scolaire présent à distance avec une signature différente + va dans school_events_to_update. + + :return: None + """ + school_event = _make_school_event( + label="Vacances de Noël", + from_date=date(2026, 12, 20), + to_date=date(2027, 1, 5), + kind=SchoolEventKind.HOLIDAY, + ) + pronote_data = _make_pronote_data(school_events=[school_event]) + + # Créer un VEVENT distant avec le même UID mais un libellé différent + # L'UID doit correspondre à celui généré par school_event_to_vevent + remote_event = _make_vevent( + uid="school-event-Vacances de Noël-2026-12-20", + summary="Vacances d'hiver", # Différent du local "Vacances de Noël" + dtstart=datetime(2026, 12, 20, 0, 0), + dtend=datetime(2027, 1, 5, 0, 0), + ) + remote_managed: list[tuple[str, Event]] = [ + ("school-event-Vacances de Noël-2026-12-20", remote_event) + ] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.school_events_to_add) == 0 + assert len(plan.school_events_to_update) == 1 + assert len(plan.school_events_to_remove) == 0 + + +def test_school_event_remove_when_not_in_local() -> None: + """Vérifie qu'un UID d'événement scolaire distant non présent en local + va dans school_events_to_remove. + + :return: None + """ + pronote_data = _make_pronote_data(school_events=[]) + + remote_event = _make_vevent( + uid="school-event-Ancien événement-2026-01-01", + summary="Ancien événement", + dtstart=datetime(2026, 1, 1, 0, 0), + dtend=datetime(2026, 1, 2, 0, 0), + ) + remote_managed: list[tuple[str, Event]] = [ + ("school-event-Ancien événement-2026-01-01", remote_event) + ] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.school_events_to_add) == 0 + assert len(plan.school_events_to_update) == 0 + assert len(plan.school_events_to_remove) == 1 + assert plan.school_events_to_remove[0] == "school-event-Ancien événement-2026-01-01" + + +# --- Tests for UID routing --- + + +def test_uid_routing_homework_to_remove() -> None: + """Vérifie qu'un UID distant commençant par 'homework-' va dans homeworks_to_remove. + + :return: None + """ + pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[]) + + remote_event = _make_vevent( + uid="homework-HW-9999", + summary="Devoir à supprimer", + dtstart=datetime(2026, 1, 20, 8, 0), + dtend=datetime(2026, 1, 20, 18, 0), + ) + remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)] + + plan = compute_plan(pronote_data, remote_managed) + + # Ne doit PAS aller dans lessons_to_remove + assert len(plan.lessons_to_remove) == 0 + assert len(plan.homeworks_to_remove) == 1 + assert plan.homeworks_to_remove[0] == "homework-HW-9999" + + +def test_uid_routing_school_event_to_remove() -> None: + """Vérifie qu'un UID distant commençant par 'school-event-' va dans school_events_to_remove. + + :return: None + """ + pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[]) + + remote_event = _make_vevent( + uid="school-event-Vacances-2026-12-20", + summary="Événement à supprimer", + dtstart=datetime(2026, 12, 20, 0, 0), + dtend=datetime(2027, 1, 5, 0, 0), + ) + remote_managed: list[tuple[str, Event]] = [("school-event-Vacances-2026-12-20", remote_event)] + + plan = compute_plan(pronote_data, remote_managed) + + # Ne doit PAS aller dans lessons_to_remove + assert len(plan.lessons_to_remove) == 0 + assert len(plan.school_events_to_remove) == 1 + assert plan.school_events_to_remove[0] == "school-event-Vacances-2026-12-20" + + +# --- Tests for empty inputs --- + + +def test_empty_inputs_empty_plan() -> None: + """Vérifie que des entrées vides produisent un plan vide. + + :return: None + """ + pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[]) + remote_managed: list[tuple[str, Event]] = [] + + plan = compute_plan(pronote_data, remote_managed) + + assert len(plan.lessons_to_add) == 0 + assert len(plan.lessons_to_update) == 0 + assert len(plan.lessons_to_remove) == 0 + assert len(plan.homeworks_to_add) == 0 + assert len(plan.homeworks_to_update) == 0 + assert len(plan.homeworks_to_remove) == 0 + assert len(plan.school_events_to_add) == 0 + assert len(plan.school_events_to_update) == 0 + assert len(plan.school_events_to_remove) == 0 + + +# --- Tests for mixed scenarios --- + + +def test_mixed_scenario() -> None: + """Vérifie un scénario mixte avec ajouts, mises à jour et suppressions. + + :return: None + """ + # Données locales + lesson1 = _make_lesson(lesson_id="L-0001") # Nouveau + lesson2 = _make_lesson(lesson_id="L-0002", subject="Mathématiques") # À mettre à jour + homework1 = _make_homework(homework_id="HW-0001") # Nouveau + + pronote_data = _make_pronote_data( + lessons=[lesson1, lesson2], + homeworks=[homework1], + school_events=[], + ) + + # Événements distants + # L-0002 existe mais avec un sujet différent + remote_lesson2 = _make_vevent( + uid="L-0002", + summary="Physique", + dtstart=datetime(2026, 1, 15, 8, 0), + dtend=datetime(2026, 1, 15, 9, 0), + ) + # L-0003 n'existe plus localement + remote_lesson3 = _make_vevent( + uid="L-0003", + summary="Ancien cours", + dtstart=datetime(2026, 1, 15, 8, 0), + dtend=datetime(2026, 1, 15, 9, 0), + ) + + remote_managed: list[tuple[str, Event]] = [ + ("L-0002", remote_lesson2), + ("L-0003", remote_lesson3), + ] + + plan = compute_plan(pronote_data, remote_managed) + + # Ajouts + assert len(plan.lessons_to_add) == 1 + assert plan.lessons_to_add[0].id == "L-0001" + assert len(plan.homeworks_to_add) == 1 + assert plan.homeworks_to_add[0].id == "HW-0001" + + # Mises à jour + assert len(plan.lessons_to_update) == 1 + assert plan.lessons_to_update[0].id == "L-0002" + + # Suppressions + assert len(plan.lessons_to_remove) == 1 + assert plan.lessons_to_remove[0] == "L-0003" + + +def test_unmanaged_events_not_in_remote_managed() -> None: + """Vérifie que remote_managed ne contient que des événements gérés. + + Le contrat indique que remote_managed ne contient déjà que des événements + marqués comme gérés. Le planner ne doit pas filtrer. + + :return: None + """ + lesson = _make_lesson(lesson_id="L-1234") + pronote_data = _make_pronote_data(lessons=[lesson]) + + # remote_managed ne contient que des événements gérés (par hypothèse) + # Donc pas besoin de tester le filtrage ici - c'est la responsabilité de list_managed_events + remote_managed: list[tuple[str, Event]] = [] + + plan = compute_plan(pronote_data, remote_managed) + + # Le cours doit être dans lessons_to_add + assert len(plan.lessons_to_add) == 1 + + +# Ensure trailing newline diff --git a/tests/unit/test_caldav_security.py b/tests/unit/test_caldav_security.py new file mode 100644 index 0000000..0e6eba5 --- /dev/null +++ b/tests/unit/test_caldav_security.py @@ -0,0 +1,379 @@ +"""Tests de sécurité pour la passerelle CalDAV et sa configuration. + +Ce module vérifie que les secrets (URL, mot de passe) ne fuient jamais dans +les représentations textuelles, les logs, les messages d'erreur ou les +chaînages d'exceptions de la configuration CalDAV et de la passerelle. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +import pytest +from pydantic import SecretStr, ValidationError + +from pronote_sync.config.settings import CalDAVSettings +from pronote_sync.errors import PronoteSyncError +from pronote_sync.sync.caldav import CalDAVGateway + +if TYPE_CHECKING: + from _pytest.logging import LogCaptureFixture + + +# Sentinelles pour détecter les fuites de secrets dans les tests +SENTINEL_URL = "https://user:pass-super-secret-12345@caldav.example.com/secret-path/" +SENTINEL_PASSWORD = "super-secret-password-67890" +SENTINEL_HTTP_URL = "http://caldav.example.com/" +SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/" +SENTINEL_HTTP_NON_LOOPBACK = "http://insecure.example.com/caldav/" + + +class TestCalDAVSettingsSecurity: + """Tests de sécurité pour la configuration CalDAV (CalDAVSettings).""" + + def test_url_redaction_in_repr(self) -> None: + """Vérifie que l'URL brute n'apparaît pas dans repr(settings).""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + repr_str = repr(settings) + assert SENTINEL_URL not in repr_str + assert "pass-super-secret-12345" not in repr_str + # L'URL est masquée par redact_url qui retourne REDACTED_URL ou une URL avec REDACTED + assert "REDACTED" in repr_str or "**********" in repr_str + + def test_url_redaction_in_str(self) -> None: + """Vérifie que l'URL brute n'apparaît pas dans str(settings).""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + str_str = str(settings) + assert SENTINEL_URL not in str_str + assert "pass-super-secret-12345" not in str_str + # L'URL est masquée par redact_url + assert "REDACTED" in str_str or "**********" in str_str + + def test_url_redaction_in_model_dump(self) -> None: + """Vérifie que l'URL brute n'apparaît pas dans model_dump().""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + dumped = settings.model_dump() + # Vérifie que l'URL n'est pas dans les valeurs du dict + for value in dumped.values(): + if isinstance(value, str): + assert SENTINEL_URL not in value + assert "pass-super-secret-12345" not in value + # Vérifie que la version rédigée est présente + assert "REDACTED" in str(dumped) + + def test_password_not_in_repr(self) -> None: + """Vérifie que le mot de passe n'apparaît pas dans repr(settings).""" + settings = CalDAVSettings( + url=SecretStr("https://caldav.example.com/"), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + repr_str = repr(settings) + assert SENTINEL_PASSWORD not in repr_str + assert "**********" in repr_str + + def test_password_not_in_str(self) -> None: + """Vérifie que le mot de passe n'apparaît pas dans str(settings).""" + settings = CalDAVSettings( + url=SecretStr("https://caldav.example.com/"), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + str_str = str(settings) + assert SENTINEL_PASSWORD not in str_str + assert "**********" in str_str + + def test_https_enforcement(self) -> None: + """Vérifie que HTTP (non-localhost) est rejeté par défaut.""" + with pytest.raises(ValidationError) as exc_info: + CalDAVSettings( + url=SecretStr(SENTINEL_HTTP_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + assert exc_info.value.error_count() >= 1 + + def test_https_accepted(self) -> None: + """Vérifie que HTTPS est accepté sans erreur.""" + settings = CalDAVSettings( + url=SecretStr("https://caldav.example.com/"), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + assert settings.url is not None + + def test_http_localhost_without_flag(self) -> None: + """Vérifie que HTTP localhost est rejeté sans allow_insecure_http.""" + with pytest.raises(ValidationError) as exc_info: + CalDAVSettings( + url=SecretStr(SENTINEL_HTTP_LOCALHOST), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + assert exc_info.value.error_count() >= 1 + + def test_http_localhost_with_flag(self) -> None: + """Vérifie que HTTP localhost est accepté avec allow_insecure_http=True.""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_HTTP_LOCALHOST), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + allow_insecure_http=True, + ) + assert settings.url is not None + + def test_http_non_loopback_with_flag(self) -> None: + """Vérifie que HTTP non-loopback est rejeté même avec allow_insecure_http=True.""" + with pytest.raises(ValidationError) as exc_info: + CalDAVSettings( + url=SecretStr(SENTINEL_HTTP_NON_LOOPBACK), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + allow_insecure_http=True, + ) + assert exc_info.value.error_count() >= 1 + + def test_validation_error_message_safe(self) -> None: + """Vérifie que les messages d'erreur de validation ne contiennent pas l'URL brute.""" + with pytest.raises(ValidationError) as exc_info: + CalDAVSettings( + url=SecretStr(SENTINEL_HTTP_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + error_str = str(exc_info.value) + assert SENTINEL_HTTP_URL not in error_str + assert "caldav.example.com" not in error_str + + +class TestCalDAVGatewaySecurity: + """Tests de sécurité pour la passerelle CalDAV (CalDAVGateway).""" + + def test_password_not_stored_in_plaintext(self) -> None: + """Vérifie que le mot de passe n'est pas stocké en clair sur l'instance.""" + settings = CalDAVSettings( + url=SecretStr("https://caldav.example.com/"), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + gateway = CalDAVGateway(settings) + # Vérifie que le mot de passe en clair n'est dans aucun attribut + for attr_name in vars(gateway): + attr_value = getattr(gateway, attr_name) + if isinstance(attr_value, str): + assert SENTINEL_PASSWORD not in attr_value + elif isinstance(attr_value, SecretStr): + # SecretStr peut contenir le secret, mais pas en clair + assert SENTINEL_PASSWORD not in str(attr_value) + + def test_error_messages_redacted(self, caplog: LogCaptureFixture) -> None: + """Vérifie que les messages d'erreur ne contiennent pas de secrets.""" + + def _leaky_client_factory(**kwargs: Any) -> None: + # Utiliser un message d'erreur qui contient des secrets dans un format détectable + raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}") + + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + gateway = CalDAVGateway(settings, client_factory=_leaky_client_factory) + + with caplog.at_level(logging.ERROR): + with pytest.raises(PronoteSyncError) as exc_info: + gateway.connect() + + # Vérifie que le message d'erreur ne contient pas les sentinelles + error_msg = str(exc_info.value) + assert SENTINEL_URL not in error_msg + assert SENTINEL_PASSWORD not in error_msg + + # Vérifie que les logs ne contiennent pas les sentinelles + for record in caplog.records: + log_msg = record.getMessage() + assert SENTINEL_URL not in log_msg + assert SENTINEL_PASSWORD not in log_msg + + def test_exception_cause_and_context_is_none(self) -> None: + """Vérifie que PronoteSyncError.__cause__ et __context__ sont None.""" + + def _leaky_client_factory(**kwargs: Any) -> None: + raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}") + + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + gateway = CalDAVGateway(settings, client_factory=_leaky_client_factory) + + with pytest.raises(PronoteSyncError) as exc_info: + gateway.connect() + + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + def test_logs_redacted_on_list_managed_events_error(self, caplog: LogCaptureFixture) -> None: + """Vérifie que les logs sont expurgés lors d'une erreur dans list_managed_events.""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + + # Créer un mock de client qui lève une exception avec des secrets + mock_client = MagicMock() + mock_principal = MagicMock() + mock_calendar = MagicMock() + mock_calendar.url = "https://caldav.example.com/cal/" + mock_calendar.date_search.side_effect = Exception( + f"Search failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}" + ) + mock_principal.calendars.return_value = [mock_calendar] + mock_client.principal.return_value = mock_principal + + gateway = CalDAVGateway(settings, client_factory=lambda **kw: mock_client) + gateway._client = mock_client + gateway._calendar = mock_calendar + + with caplog.at_level(logging.ERROR): + with pytest.raises(PronoteSyncError) as exc_info: + gateway.list_managed_events(start=datetime(2026, 1, 15), end=datetime(2026, 1, 20)) + + # Vérifie que le message d'erreur ne contient pas les sentinelles + error_msg = str(exc_info.value) + assert SENTINEL_URL not in error_msg + assert SENTINEL_PASSWORD not in error_msg + + # Vérifie que les logs ne contiennent pas les sentinelles + for record in caplog.records: + log_msg = record.getMessage() + assert SENTINEL_URL not in log_msg + assert SENTINEL_PASSWORD not in log_msg + + # Vérifie que l'exception n'est chaînée à aucune exception brute + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + def test_logs_redacted_on_upsert_event_error(self, caplog: LogCaptureFixture) -> None: + """Vérifie que les logs sont expurgés lors d'une erreur dans upsert_event.""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + + # Créer un mock de calendrier dont la recherche par UID lève une + # exception avec des secrets (simule une fuite de la bibliothèque caldav) + mock_calendar = MagicMock() + mock_calendar.get_event_by_uid.side_effect = Exception( + f"Save failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}" + ) + + gateway = CalDAVGateway(settings) + gateway._calendar = mock_calendar + + with caplog.at_level(logging.ERROR): + with pytest.raises(PronoteSyncError) as exc_info: + gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-uid") + + # Vérifie que le message d'erreur ne contient pas les sentinelles + error_msg = str(exc_info.value) + assert SENTINEL_URL not in error_msg + assert SENTINEL_PASSWORD not in error_msg + + # Vérifie que les logs ne contiennent pas les sentinelles + for record in caplog.records: + log_msg = record.getMessage() + assert SENTINEL_URL not in log_msg + assert SENTINEL_PASSWORD not in log_msg + + # Vérifie que l'exception n'est chaînée à aucune exception brute + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + def test_logs_redacted_on_delete_event_error(self, caplog: LogCaptureFixture) -> None: + """Vérifie que les logs sont expurgés lors d'une erreur dans delete_event.""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + + # Créer un mock de calendrier qui lève une exception avec des secrets + mock_calendar = MagicMock() + mock_calendar.get_event_by_uid.side_effect = Exception( + f"Delete failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}" + ) + + gateway = CalDAVGateway(settings) + gateway._calendar = mock_calendar + + with caplog.at_level(logging.ERROR): + with pytest.raises(PronoteSyncError) as exc_info: + gateway.delete_event("test-uid") + + # Vérifie que le message d'erreur ne contient pas les sentinelles + error_msg = str(exc_info.value) + assert SENTINEL_URL not in error_msg + assert SENTINEL_PASSWORD not in error_msg + + # Vérifie que les logs ne contiennent pas les sentinelles + for record in caplog.records: + log_msg = record.getMessage() + assert SENTINEL_URL not in log_msg + assert SENTINEL_PASSWORD not in log_msg + + # Vérifie que l'exception n'est chaînée à aucune exception brute + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + def test_redacted_url_stored_in_gateway(self) -> None: + """Vérifie que l'URL rédigée est stockée sur l'instance de la passerelle.""" + settings = CalDAVSettings( + url=SecretStr(SENTINEL_URL), + username="test-user", + password=SecretStr(SENTINEL_PASSWORD), + calendar_path="/cal/", + ) + gateway = CalDAVGateway(settings) + # Vérifie que l'URL rédigée est stockée + assert gateway._redacted_url is not None + assert SENTINEL_URL not in gateway._redacted_url + assert "REDACTED" in gateway._redacted_url + # Vérifie que l'URL brute n'est pas stockée en clair + assert gateway._url_secret is not None + assert SENTINEL_URL not in str(gateway._url_secret) diff --git a/tests/unit/test_sync_serialization.py b/tests/unit/test_sync_serialization.py new file mode 100644 index 0000000..3a364a1 --- /dev/null +++ b/tests/unit/test_sync_serialization.py @@ -0,0 +1,488 @@ +"""Tests unitaires pour la sérialisation des modèles Pronote en VEVENT iCalendar. + +Ce module vérifie que les fonctions de conversion des modèles Pronote +(:class:`Lesson`, :class:`Homework`, :class:`SchoolEvent`) en composants +:class:`icalendar.Event` produisent les propriétés attendues (UID, SUMMARY, +DTSTART, DTEND, STATUS, CATEGORIES, etc.) et que la signature sémantique +déterministe est correctement calculée. +""" + +from __future__ import annotations + +from datetime import date, datetime + +from icalendar import Calendar, Event + +from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind +from pronote_sync.models.homework import Homework +from pronote_sync.sync.serialization import ( + MANAGED_PROPERTY, + MANAGED_VALUE, + component_to_signature, + homework_to_vevent, + lesson_to_vevent, + model_to_vcalendar_text, + school_event_to_vevent, +) + +# --- 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, + teachers: tuple[str, ...] = ("Prof Dupont",), + rooms: tuple[str, ...] = ("Salle 101",), + 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 teachers: Professeurs. + :param rooms: Salles. + :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, + teachers=teachers, + rooms=rooms, + 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", + teachers: tuple[str, ...] = ("Prof Dupont",), + 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 teachers: Professeurs. + :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, + teachers=teachers, + 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, + ) + + +# --- lesson_to_vevent tests --- + + +def test_model_to_vcalendar_text_produces_complete_vcalendar() -> None: + """Vérifie que la sérialisation produit un VCALENDAR complet avec un VEVENT. + + :return: None + """ + lesson = _make_lesson() + output = model_to_vcalendar_text(lesson) + calendar = Calendar.from_ical(output) + + assert "VERSION:2.0" in output + assert "PRODID" in output + assert output.startswith("BEGIN:VCALENDAR") + + vevents = calendar.walk("VEVENT") + assert len(vevents) == 1 + + +def test_lesson_to_vevent_normal() -> None: + """Vérifie qu'un cours normal produit un VEVENT avec les bonnes propriétés. + + Un cours normal doit avoir : + - UID = id du cours + - SUMMARY = matière + - DTSTART/DTEND = dates de début/fin + - STATUS = CONFIRMED + - CATEGORIES contient "Pronote" + - MANAGED_PROPERTY présent avec MANAGED_VALUE + """ + lesson = _make_lesson() + event = lesson_to_vevent(lesson) + + assert str(event.get("UID")) == "L-1234" + assert str(event.get("SUMMARY")) == "Mathématiques" + assert event.get("DTSTART").dt == datetime(2026, 1, 15, 8, 0) + assert event.get("DTEND").dt == datetime(2026, 1, 15, 9, 0) + assert str(event.get("STATUS")) == "CONFIRMED" + + categories = event.get("CATEGORIES") + assert categories is not None + assert "Pronote" in categories.cats + + assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE + + +def test_lesson_to_vevent_cancelled() -> None: + """Vérifie qu'un cours annulé a STATUS=CANCELLED et CATEGORIES contient 'Annulé'. + + :return: None + """ + lesson = _make_lesson(status=LessonStatus.CANCELLED) + event = lesson_to_vevent(lesson) + + assert str(event.get("STATUS")) == "CANCELLED" + categories = event.get("CATEGORIES") + assert categories is not None + assert "Pronote" in categories.cats + assert "Annulé" in categories.cats + + +def test_lesson_to_vevent_moved() -> None: + """Vérifie qu'un cours déplacé a CATEGORIES contient 'Déplacé'. + + :return: None + """ + lesson = _make_lesson(status=LessonStatus.MOVED) + event = lesson_to_vevent(lesson) + + assert str(event.get("STATUS")) == "CONFIRMED" + categories = event.get("CATEGORIES") + assert categories is not None + assert "Pronote" in categories.cats + assert "Déplacé" in categories.cats + + +def test_lesson_to_vevent_description() -> None: + """Vérifie que la description contient tous les champs renseignés. + + :return: None + """ + lesson = _make_lesson( + subject="Mathématiques", + teachers=("Prof Dupont", "Prof Martin"), + rooms=("Salle 101", "Salle 102"), + content="Chapitre 1", + ) + event = lesson_to_vevent(lesson) + description = str(event.get("DESCRIPTION")) + + assert "Matière: Mathématiques" in description + assert "Professeur(s): Prof Dupont, Prof Martin" in description + assert "Salle(s): Salle 101, Salle 102" in description + assert "Contenu: Chapitre 1" in description + + +# --- homework_to_vevent tests --- + + +def test_homework_to_vevent_uid_prefix() -> None: + """Vérifie que l'UID d'un devoir est préfixé par 'homework-'. + + :return: None + """ + homework = _make_homework() + event = homework_to_vevent(homework) + + assert str(event.get("UID")) == "homework-HW-5678" + + +def test_homework_to_vevent_status() -> None: + """Vérifie qu'un devoir a STATUS=NEEDS-ACTION. + + :return: None + """ + homework = _make_homework() + event = homework_to_vevent(homework) + + assert str(event.get("STATUS")) == "NEEDS-ACTION" + + +def test_homework_to_vevent_categories() -> None: + """Vérifie que les CATEGORIES d'un devoir contiennent 'Pronote' et 'Devoir'. + + :return: None + """ + homework = _make_homework() + event = homework_to_vevent(homework) + + categories = event.get("CATEGORIES") + assert categories is not None + assert "Pronote" in categories.cats + assert "Devoir" in categories.cats + + +def test_homework_to_vevent_dtstart_dtend() -> None: + """Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00). + + :return: None + """ + homework = _make_homework(due_on=date(2026, 1, 20)) + event = homework_to_vevent(homework) + + assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0) + assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0) + + +def test_homework_to_vevent_summary() -> None: + """Vérifie que le SUMMARY d'un devoir est préfixé par 'Devoir: '. + + :return: None + """ + homework = _make_homework(subject="Mathématiques") + event = homework_to_vevent(homework) + + assert str(event.get("SUMMARY")) == "Devoir: Mathématiques" + + +def test_homework_to_vevent_managed_marker() -> None: + """Vérifie que le marqueur MANAGED_PROPERTY est présent. + + :return: None + """ + homework = _make_homework() + event = homework_to_vevent(homework) + + assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE + + +# --- school_event_to_vevent tests --- + + +def test_school_event_to_vevent_uid_prefix() -> None: + """Vérifie que l'UID d'un événement scolaire est préfixé correctement. + + :return: None + """ + school_event = _make_school_event( + label="Vacances de Noël", + from_date=date(2026, 12, 20), + ) + event = school_event_to_vevent(school_event) + + assert str(event.get("UID")) == "school-event-Vacances de Noël-2026-12-20" + + +def test_school_event_to_vevent_status() -> None: + """Vérifie qu'un événement scolaire a STATUS=CONFIRMED. + + :return: None + """ + school_event = _make_school_event() + event = school_event_to_vevent(school_event) + + assert str(event.get("STATUS")) == "CONFIRMED" + + +def test_school_event_to_vevent_categories() -> None: + """Vérifie que les CATEGORIES contiennent 'Pronote' et la valeur du kind. + + :return: None + """ + school_event = _make_school_event(kind=SchoolEventKind.HOLIDAY) + event = school_event_to_vevent(school_event) + + categories = event.get("CATEGORIES") + assert categories is not None + assert "Pronote" in categories.cats + assert "holiday" in categories.cats + + +def test_school_event_to_vevent_dtstart_dtend() -> None: + """Vérifie que DTSTART et DTEND sont des vDate (pas vDatetime). + + :return: None + """ + school_event = _make_school_event( + from_date=date(2026, 12, 20), + to_date=date(2027, 1, 5), + ) + event = school_event_to_vevent(school_event) + + from icalendar import vDate + + assert isinstance(event.get("DTSTART"), vDate) + assert isinstance(event.get("DTEND"), vDate) + assert event.get("DTSTART").dt == date(2026, 12, 20) + assert event.get("DTEND").dt == date(2027, 1, 5) + + +def test_school_event_to_vevent_managed_marker() -> None: + """Vérifie que le marqueur MANAGED_PROPERTY est présent. + + :return: None + """ + school_event = _make_school_event() + event = school_event_to_vevent(school_event) + + assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE + + +# --- component_to_signature tests --- + + +def test_component_to_signature_ignores_volatile_properties() -> None: + """Vérifie que deux VEVENTs ne différant que par DTSTAMP/CREATED/LAST-MODIFIED/SEQUENCE + produisent la même signature. + + :return: None + """ + # Créer deux événements identiques sauf pour les propriétés volatiles + event1 = Event() + event1.add("UID", "test-uid") + event1.add("SUMMARY", "Test Event") + event1.add("DTSTART", datetime(2026, 1, 15, 8, 0)) + event1.add("DTEND", datetime(2026, 1, 15, 9, 0)) + event1.add("STATUS", "CONFIRMED") + event1.add("DTSTAMP", datetime(2026, 1, 1, 0, 0)) # Différent + event1.add("CREATED", datetime(2026, 1, 1, 0, 0)) # Différent + + event2 = Event() + event2.add("UID", "test-uid") + event2.add("SUMMARY", "Test Event") + event2.add("DTSTART", datetime(2026, 1, 15, 8, 0)) + event2.add("DTEND", datetime(2026, 1, 15, 9, 0)) + event2.add("STATUS", "CONFIRMED") + event2.add("DTSTAMP", datetime(2026, 1, 2, 0, 0)) # Différent + event2.add("LAST-MODIFIED", datetime(2026, 1, 2, 0, 0)) # Différent + event2.add("SEQUENCE", 1) # Différent + + sig1 = component_to_signature(event1) + sig2 = component_to_signature(event2) + + assert sig1 == sig2 + + +def test_component_to_signature_different_summary() -> None: + """Vérifie que deux VEVENTs avec SUMMARY différent produisent des signatures différentes. + + :return: None + """ + event1 = Event() + event1.add("UID", "test-uid") + event1.add("SUMMARY", "Event 1") + event1.add("DTSTART", datetime(2026, 1, 15, 8, 0)) + event1.add("DTEND", datetime(2026, 1, 15, 9, 0)) + event1.add("STATUS", "CONFIRMED") + + event2 = Event() + event2.add("UID", "test-uid") + event2.add("SUMMARY", "Event 2") # Différent + event2.add("DTSTART", datetime(2026, 1, 15, 8, 0)) + event2.add("DTEND", datetime(2026, 1, 15, 9, 0)) + event2.add("STATUS", "CONFIRMED") + + sig1 = component_to_signature(event1) + sig2 = component_to_signature(event2) + + assert sig1 != sig2 + + +def test_component_to_signature_deterministic() -> None: + """Vérifie que la signature est déterministe (même entrée → même sortie). + + :return: None + """ + event = Event() + event.add("UID", "test-uid") + event.add("SUMMARY", "Test Event") + event.add("DTSTART", datetime(2026, 1, 15, 8, 0)) + event.add("DTEND", datetime(2026, 1, 15, 9, 0)) + event.add("STATUS", "CONFIRMED") + + sig1 = component_to_signature(event) + sig2 = component_to_signature(event) + + assert sig1 == sig2 + + +def test_component_to_signature_includes_categories() -> None: + """Vérifie que les CATEGORIES sont incluses dans la signature (triées). + + :return: None + """ + event = Event() + event.add("UID", "test-uid") + event.add("SUMMARY", "Test Event") + event.add("DTSTART", datetime(2026, 1, 15, 8, 0)) + event.add("DTEND", datetime(2026, 1, 15, 9, 0)) + event.add("STATUS", "CONFIRMED") + event.add("CATEGORIES", ["Pronote", "Devoir"]) + + sig = component_to_signature(event) + + # Les catégories doivent apparaître dans la signature + assert "categories=devoir,pronote" in sig + + +def test_component_to_signature_includes_managed_property() -> None: + """Vérifie que MANAGED_PROPERTY est incluse dans la signature. + + :return: None + """ + event = Event() + event.add("UID", "test-uid") + event.add("SUMMARY", "Test Event") + event.add("DTSTART", datetime(2026, 1, 15, 8, 0)) + event.add("DTEND", datetime(2026, 1, 15, 9, 0)) + event.add("STATUS", "CONFIRMED") + event.add(MANAGED_PROPERTY, MANAGED_VALUE) + + sig = component_to_signature(event) + + assert f"managed={MANAGED_VALUE.lower()}" in sig + + +# Ensure trailing newline diff --git a/tests/unit/test_uid_equivalence.py b/tests/unit/test_uid_equivalence.py new file mode 100644 index 0000000..818813b --- /dev/null +++ b/tests/unit/test_uid_equivalence.py @@ -0,0 +1,189 @@ +"""Tests unitaires pour l'équivalence des UIDs entre les sources iCal et pronotepy. + +Ce module vérifie que les fonctions de normalisation et de génération +d'UIDs produisent les mêmes résultats pour les mêmes données, garantissant +qu'aucun doublon ou suppression artificielle ne se produit lors du +changement de source. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from icalendar import Calendar + +from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid + +# --- Test 1: normalize_pronote_uid strips temporal suffix --- + + +def test_normalize_pronote_uid_strips_temporal_suffix() -> None: + """Vérifie que normalize_pronote_uid produit un UID canonique stable. + + Un UID brut Pronote avec suffixe temporel comme + ``L-12345-20260905T120000Z-Index-Education`` doit être normalisé en + ``L-12345``. + + :return: None + """ + raw_uid = "L-12345-20260905T120000Z-Index-Education" + normalized = normalize_pronote_uid(raw_uid) + assert normalized == "L-12345" + + +def test_normalize_pronote_uid_strips_education_suffix_only() -> None: + """Vérifie que normalize_pronote_uid retire aussi le suffixe -Index-Education seul. + + :return: None + """ + raw_uid = "L-12345-Index-Education" + normalized = normalize_pronote_uid(raw_uid) + assert normalized == "L-12345" + + +def test_normalize_pronote_uid_idempotent() -> None: + """Vérifie que normalize_pronote_uid est idempotente. + + :return: None + """ + raw_uid = "L-12345" + normalized = normalize_pronote_uid(raw_uid) + assert normalized == "L-12345" + + +# --- Test 2: generate_deterministic_uid is stable --- + + +def test_generate_deterministic_uid_is_stable() -> None: + """Vérifie que generate_deterministic_uid produit le même UID pour les mêmes entrées. + + :return: None + """ + start = datetime(2026, 9, 5, 8, 0, 0) + end = datetime(2026, 9, 5, 9, 0, 0) + subject = "Mathématiques" + teachers = ["M. Dupont"] + rooms = ["204"] + group = "Classe entière" + + uid1 = generate_deterministic_uid( + start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group + ) + uid2 = generate_deterministic_uid( + start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group + ) + + assert uid1 == uid2 + assert len(uid1) == 12 + assert all(c in "0123456789abcdef" for c in uid1) + + +def test_generate_deterministic_uid_ignores_order() -> None: + """Vérifie que l'ordre des enseignants/salles n'affecte pas le résultat. + + :return: None + """ + start = datetime(2026, 9, 5, 8, 0, 0) + end = datetime(2026, 9, 5, 9, 0, 0) + subject = "Mathématiques" + + uid1 = generate_deterministic_uid( + start=start, + end=end, + subject=subject, + teachers=["M. Dupont", "Mme Martin"], + rooms=["204", "205"], + group=None, + ) + uid2 = generate_deterministic_uid( + start=start, + end=end, + subject=subject, + teachers=["Mme Martin", "M. Dupont"], + rooms=["205", "204"], + group=None, + ) + + assert uid1 == uid2 + + +# --- Test 3: iCal and pronotepy produce same UID with raw UID --- + + +def test_ical_and_pronotepy_produce_same_uid_with_raw_uid() -> None: + """Vérifie que iCal et pronotepy produisent le même UID avec un UID brut. + + Comme les deux sources utilisent normalize_pronote_uid, le résultat doit + être identique. + + :return: None + """ + raw_uid = "Edt_12345@index-education.net-20260905T120000Z-Index-Education" + + # Simulation du chemin iCal + ical_normalized = normalize_pronote_uid(raw_uid) + + # Simulation du chemin pronotepy + pronotepy_normalized = normalize_pronote_uid(raw_uid) + + assert ical_normalized == pronotepy_normalized + assert ical_normalized == "Edt_12345@index-education.net" + + +# --- Test 4: iCal and pronotepy produce same UID with fallback --- + + +def test_ical_and_pronotepy_produce_same_uid_with_fallback() -> None: + """Vérifie que iCal et pronotepy produisent le même UID déterministe en l'absence d'UID brut. + + :return: None + """ + start = datetime(2026, 9, 5, 8, 0, 0) + end = datetime(2026, 9, 5, 9, 0, 0) + subject = "Mathématiques" + teachers = ["M. Dupont"] + rooms = ["204"] + group = "Classe entière" + + # Simulation du chemin iCal (parse_ical utilise generate_deterministic_uid) + ical_uid = generate_deterministic_uid( + start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group + ) + + # Simulation du chemin pronotepy (get_lessons utilise generate_deterministic_uid) + pronotepy_uid = generate_deterministic_uid( + start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group + ) + + assert ical_uid == pronotepy_uid + + +# --- Test 5: UID from iCal fixture is normalized --- + + +def test_uid_from_ical_fixture_is_normalized() -> None: + """Vérifie que les UIDs extraits du fixture pronote-4e.ics sont normalisés correctement. + + :return: None + """ + fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics" + with open(fixture_path, "rb") as f: + cal = Calendar.from_ical(f.read()) + + normalized_uids = [] + for component in cal.walk("VEVENT"): + raw_uid = str(component.get("UID")) + normalized = normalize_pronote_uid(raw_uid) + normalized_uids.append(normalized) + + # Vérifie que tous les UIDs sont normalisés (plus courts que les UIDs bruts) + assert len(normalized_uids) >= 2 + + # Vérifie que les UIDs normalisés ne contiennent pas de suffixes temporels + for uid in normalized_uids: + assert "-Index-Education" not in uid + assert "-2026" not in uid # Pas de suffixe temporel + + # Vérifie que le premier UID est bien normalisé + assert "Edt_12345@index-education.net" in normalized_uids