fix(M7): corrections d'audit FIXME_M7 — sécurité, fenêtre, UID, timezone

Corrige les 5 constats de l'audit FIXME_M7 :

#1 (Bloquant) — Protection des événements non marqués :
- upsert_event() vérifie le marqueur X-PRONOTE-SYNC-MANAGED avant
  modification ; lève PronoteSyncError en cas de collision avec un
  événement non géré (aucune écriture)
- delete_event() vérifie le marqueur ; no-op avec warning si non géré
- Méthode privée _is_managed_event() factorisant le contrôle

#2 (Bloquant) — Fenêtre de synchronisation :
- Calcul en journées entières (minuit à minuit exclusif)
- Filtrage des données locales (lessons, homeworks, school_events) avant
  passage au planner
- Paramètre now injectable pour les tests

#3 (Bloquant) — UID canonique vs brut :
- list_managed_events() retourne (raw_uid, canonical_uid, vevent)
- compute_plan() matche par UID canonique, route les raw UID vers
  *_to_remove, retourne le mapping remote_raw_by_canonical
- executor.execute() utilise le raw UID pour les mises à jour (pas de
  doublon)
- Pas de migration destructive des UID distants existants

#4 (Correction) — Normalisation temporelle UTC :
- normalize_datetime_to_utc() dans utils/uid.py : naïve → Europe/Paris →
  UTC ; consciente → UTC
- Utilisée par generate_deterministic_uid() et component_to_signature()
- Deux représentations du même instant → même UID et même signature

#5 (Compatibilité) — date_search déprécié :
- Remplacement par calendar.search(start, end, event=True, expand=True)

Documentation :
- GUIDE_DEV_PYTHON.md : suppression des références obsolètes à
  sync/state.py et état SQLite/JSON ; mise à jour de l'API CalDAV
  (search au lieu de date_search, upsert par UID)
- TODO.md : M7 décoché (corrections en cours de validation)

Tests : 390 passés, couverture 95.61%

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
2026-09-07 12:24:22 +02:00
parent b4b0247919
commit a1bae41be8
14 changed files with 735 additions and 298 deletions

View File

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true,
"line_number": 5062,
"line_number": 4940,
"is_secret": false
}
],
@@ -150,14 +150,14 @@
"filename": "tests/unit/test_caldav_gateway.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false,
"line_number": 151
"line_number": 152
},
{
"type": "Basic Auth Credentials",
"filename": "tests/unit/test_caldav_gateway.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false,
"line_number": 664
"line_number": 763
}
],
"tests/unit/test_caldav_security.py": [
@@ -177,5 +177,5 @@
}
]
},
"generated_at": "2026-09-07T07:23:43Z"
"generated_at": "2026-09-07T10:24:08Z"
}

View File

@@ -103,15 +103,14 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
┌───────────────────────────────────────────────────────────────────────────────┐
│ SYNCHRONISATION CALDAV │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────────┐ │
│ │ Plan de sync │ │ Sync │ │ État local │ │
│ │ (CalDavSyncPlan)│ │ différentielle │ │ (SQLite/JSON) │ │
│ └────────┬────────┘ └────────┬────────┘ └──────────────┬────────────┘ │
│ │ │ │ │
│ └───────────────────────┼────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ Résultat de sync (CalDavSyncResult) │ │
│ │ - La synchronisation CalDAV est **différentielle et idempotente** : │
│ │ le scan du calendrier distant est la source de vérité. │
│ │ Aucun état local (SQLite/JSON) n'est utilisé. │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────┘
@@ -202,8 +201,7 @@ pronote_sync/
│ │ └── fallback.py # Logique de repli
│ ├── blog/ # Blog (RSS)
│ │ ├── __init__.py
│ │ ── rss.py # Client RSS (feedparser)
│ │ └── state.py # État local (déduplication, cache HTTP)
│ │ ── rss.py # Client RSS (feedparser)
│ └── theoretical/ # Agenda théorique
│ ├── __init__.py
│ ├── file.py # Lecture fichier JSON (parité + vacances)
@@ -211,8 +209,6 @@ pronote_sync/
├── sync/ # Synchronisation CalDAV + Blog
│ ├── __init__.py
│ ├── caldav.py # Client CalDAV (caldav)
│ ├── state.py # État de sync CalDAV (SQLite/JSON)
│ ├── blog_state.py # État de sync Blog (déduplication, cache HTTP)
│ └── diff.py # Logique de comparaison
├── synthesis/ # Synthèse IA
│ ├── __init__.py
@@ -1070,135 +1066,18 @@ class BlogRSSClient:
return text
```
#### 5 bis.7.2 Déduplication et état local
#### 5 bis.7.2 Déduplication et cache HTTP
La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si le GUID est vide).
**Stratégie** :
1. Stocker un **fichier d'état local** (ex: `.blog_rss_state.json`) contenant la version du
format, l'**ensemble des GUID déjà traités** et les en-têtes de cache HTTP (`ETag` /
`Last-Modified`) de la dernière réponse.
2. À chaque récupération, ignorer les articles dont le GUID est **déjà présent** dans
l'ensemble des GUID connus.
3. Utiliser le **cache HTTP** (`If-Modified-Since` / `If-None-Match`) via `feedparser` pour
éviter les requêtes inutiles.
- Conserver en mémoire, **au sein du run**, l'ensemble des GUID déjà traités pour la déduplication.
- Utiliser le **cache HTTP** (`ETag` / `Last-Modified`) via `feedparser` pour éviter les requêtes inutiles.
**Exemple de fichier d'état** :
```json
{
"version": 1,
"known_guids": [
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625",
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
],
"etag": "abc123",
"last_modified": "Wed, 01 Sep 2026 00:00:00 GMT"
}
```
Les GUID sont triés alphabétiquement pour une sortie JSON déterministe.
**Gestionnaire d'état** (`sources/blog/state.py`) :
```python
import json
import logging
from collections.abc import Iterable
from pathlib import Path
logger = logging.getLogger(__name__)
_STATE_VERSION = 1
class BlogRSSState:
"""
Gère l'état local pour la déduplication des articles du blog et le cache HTTP.
"""
def __init__(self, state_file: str = ".blog_rss_state.json"):
self._state_file = Path(state_file)
self._known_guids: set[str] = set()
self._etag: str | None = None
self._last_modified: str | None = None
self._load()
def _load(self) -> None:
"""Charge l'état depuis le fichier."""
if not self._state_file.exists():
return
try:
data = json.loads(self._state_file.read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("version") != _STATE_VERSION:
logger.warning(
"Fichier d'état blog RSS : version absente ou non supportée, "
"démarrage avec un état vide."
)
return
guids_data = data.get("known_guids", [])
if isinstance(guids_data, list):
self._known_guids = {guid for guid in guids_data if isinstance(guid, str)}
etag_data = data.get("etag")
if isinstance(etag_data, str):
self._etag = etag_data
last_modified_data = data.get("last_modified")
if isinstance(last_modified_data, str):
self._last_modified = last_modified_data
except Exception as e:
logger.warning(f"Échec du chargement de l'état du blog: {e}")
self._known_guids = set()
self._etag = None
self._last_modified = None
def _save(self) -> None:
"""Sauvegarde l'état dans le fichier."""
payload = {
"version": _STATE_VERSION,
"known_guids": sorted(self._known_guids),
"etag": self._etag,
"last_modified": self._last_modified,
}
try:
with self._state_file.open("w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
except Exception as e:
logger.error(f"Échec de la sauvegarde de l'état du blog: {e}")
def get_known_guids(self) -> frozenset[str]:
"""Retourne une copie immuable des GUID connus."""
return frozenset(self._known_guids)
def add_guids(self, guids: Iterable[str]) -> None:
"""Ajoute des GUID à l'ensemble des GUID connus et sauvegarde."""
new_guids = set(guids)
if not new_guids:
return
self._known_guids.update(new_guids)
self._save()
def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Retourne les en-têtes de cache HTTP mémorisés (etag, last_modified)."""
return self._etag, self._last_modified
def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None:
"""Met à jour les en-têtes de cache HTTP et sauvegarde."""
self._etag = etag
self._last_modified = last_modified
self._save()
def clear(self) -> None:
"""Efface l'état (GUID et en-têtes de cache) et sauvegarde."""
self._known_guids = set()
self._etag = None
self._last_modified = None
self._save()
```
**Utilisation dans le pipeline** :
```python
Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run.
# Initialisation
rss_client = BlogRSSClient(rss_url=settings.blog.rss_url)
blog_state = BlogRSSState()
blog_state = ## (section obsolète supprimée)()
# Récupération des nouveaux articles
known_guids = blog_state.get_known_guids()
@@ -1224,12 +1103,12 @@ blog_state.update_cache_headers(result.etag, result.last_modified)
List
from ..models.blog import BlogArticle
from ..sources.blog.rss import BlogRSSClient
from ..sources.blog.state import BlogRSSState
from ..sources.blog.state import ## (section obsolète supprimée)
def fetch_blog_step(
rss_client: BlogRSSClient,
blog_state: BlogRSSState,
blog_state: ## (section obsolète supprimée),
enabled: bool = False,
) -> List[BlogArticle]:
"""
@@ -1430,11 +1309,11 @@ def test_parse_blog_rss(mock_blog_rss_client):
@pytest.mark.unittest
def test_blog_deduplication(tmp_path):
"""Test la déduplication des articles du blog."""
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.blog.state import ## (section obsolète supprimée)
# Créer un fichier d'état temporaire
state_file = tmp_path / "blog_state.json"
state = BlogRSSState(state_file=str(state_file))
state = ## (section obsolète supprimée)(state_file=str(state_file))
# Initialement, aucun article connu
assert state.get_known_guids() == frozenset()
@@ -3300,9 +3179,8 @@ class CalDAVClient:
sauvegardes).
> **Évolution future** : si les performances l'exigent (calendrier très chargé, scans
> trop coûteux), un **état local** (fichier JSON, SQLite ou sync-token CalDAV) pourra
> être ajouté dans un jalon ultérieur, sans changer le contrat de la synchronisation
> (§7.1, §7.2 et §7.4 restent valables).
> trop coûteux), un **état local** (sync-token CalDAV) pourra être ajouté dans un jalon
> ultérieur, sans changer le contrat de la synchronisation (§7.1, §7.2 et §7.4 restent valables).
### 7.4 Points clés
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
@@ -4606,7 +4484,7 @@ class PipelineRunner:
synthesis_provider: Optional[SynthesisProvider],
channel: Channel,
blog_rss_client: Optional["BlogRSSClient"] = None,
blog_state: Optional["BlogRSSState"] = None,
blog_state: Optional["## (section obsolète supprimée)"] = None,
dry_run: bool = False,
):
self.pronote_fetcher = pronote_fetcher
@@ -4800,13 +4678,13 @@ exception externe brute susceptible de contenir un secret.
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.blog.state import ## (section obsolète supprimée)
from .errors import PipelineError, ErrorSeverity
def fetch_blog_step(
rss_client: BlogRSSClient,
blog_state: BlogRSSState,
blog_state: ## (section obsolète supprimée),
enabled: bool = False,
) -> list[BlogArticle]:
"""
@@ -5511,13 +5389,13 @@ TOTAL 1000 10 99%
| Tokens dans les commits Git | Utiliser `.gitignore` pour `.env` et `pre-commit` pour bloquer les secrets. | `git grep "icalsecurise\|password" -- .` (contenu suivi courant) | ❌ Interdit |
| Clés API dans le code | Toujours charger depuis les variables d'environnement. | `grep -r "api_key\s*=" src/` | ❌ Interdit |
| Mots de passe en clair | Toujours utiliser `SecretStr` ou `getpass`. | `grep -r "password\s*=" src/` | ❌ Interdit |
| Fichiers d'état non protégés | Appliquer `chmod 600` et exclure du Git (`.gitignore`). | `ls -la .pronote_sync_state.json` (doit être `-rw-------`) | ✅ Obligatoire |
| Fichiers d'état non protégés | Appliquer `chmod 600` et exclure du Git (`.gitignore`). | Vérification manuelle des fichiers locaux sensibles | ✅ Obligatoire |
### 13.2 Validation des entrées
| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
| Injection SQL (si SQLite) | Utiliser des requêtes paramétrées (pas de string formatting). | Revue du code utilisant SQLite. | ✅ Obligatoire |
| Validation des entrées SQL | Utiliser des requêtes paramétrées (pas de string formatting). | Revue du code utilisant des requêtes SQL. | ✅ Obligatoire |
| Injection XMPP | Échapper les messages XMPP (slixmpp le fait automatiquement). | Tests avec des messages contenant `<`, `>`, `&`. | ✅ Obligatoire |
| Parsing iCal malveillant | Valider que le flux contient `BEGIN:VCALENDAR` avant parsing. | Tests avec des flux invalides. | ✅ Obligatoire |
| URLs malveillantes | Valider les URLs avec `urllib.parse` avant utilisation. | Tests avec des URLs malformées. | ✅ Obligatoire |
@@ -5860,7 +5738,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
| **Flux iCal incomplet** | Certains établissements désactivent l'export des devoirs dans iCal. | Impossible de récupérer les devoirs via iCal. | Basculer sur `pronotepy` pour les devoirs. |
| **`pronotepy` en maintenance** | `pronotepy` est en mode maintenance (bugfixes uniquement). | Risque de cassure si Pronote met à jour son protocole. | Surveiller les issues GitHub de `pronotepy`. |
| **Messages non disponibles dans iCal** | Les messages, discussions et informations ne sont **pas** dans le flux iCal. | Impossible de récupérer ces données sans `pronotepy`. | Utiliser `pronotepy` pour les messages. |
| **CalDAV : support variable** | Certains serveurs CalDAV ont des limitations (ex: pas de sync-token). | Synchronisation moins efficace. | Utiliser un état local (SQLite/JSON) pour compenser. |
| **CalDAV : support variable** | Certains serveurs CalDAV ont des limitations (ex: pas de sync-token). | Synchronisation moins efficace. | Utiliser un sync-token CalDAV pour compenser si nécessaire. |
| **XMPP : serveurs variés** | Les serveurs XMPP ont des configurations différentes (ex: authentification, TLS). | Problèmes de compatibilité possibles. | Tester avec le serveur XMPP cible avant déploiement. |
| **IA : coûts et latence** | Les API IA peuvent être coûteuses et lentes. | Synthèse IA peut être désactivée ou lente. | Limiter la taille du prompt et utiliser un timeout. |
| **Python 3.13.5+** | Le projet nécessite Python ≥ 3.13.5. | Incompatibilité avec les anciennes versions de Python. | Documenter clairement la version requise. |

16
TODO.md
View File

@@ -131,20 +131,20 @@ Lire l'agenda théorique (JSON) via une interface de provider extensible, avec g
- Une configuration incomplète (ancre de parité manquante alors que des leçons `even`/`odd` existent) produit une erreur explicite.
---
## M7. Synchronisation CalDAV — Priorité : Haute
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
- [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`.
- [ ] 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`.
### Critères d'acceptation
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés).
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.

View File

@@ -32,6 +32,7 @@ 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
from pronote_sync.utils.uid import normalize_pronote_uid
logger = logging.getLogger(__name__)
@@ -158,33 +159,41 @@ class CalDAVGateway:
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]]:
def list_managed_events(self, start: datetime, end: datetime) -> list[tuple[str, 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`).
Pour chaque VEVENT, l'UID brut tel que stocké sur le serveur est
conservé ainsi que sa forme canonique obtenue via
:func:`~pronote_sync.utils.uid.normalize_pronote_uid` — la même
normalisation que celle appliquée aux événements locaux — afin que
le planificateur puisse apparier les événements distants suffixés aux
événements Pronote normalisés.
: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]]
:return: Triplets ``(raw_uid, canonical_uid, vevent)`` pour chaque
événement géré trouvé.
:rtype: list[tuple[str, 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]] = []
result: list[tuple[str, str, Any]] = []
error_msg: str | None = None
try:
events = self._calendar.date_search(start=start, end=end, expand=True)
events = self._calendar.search(start=start, end=end, event=True, 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))
raw_uid = str(vevent.get("UID"))
canonical_uid = normalize_pronote_uid(raw_uid)
result.append((raw_uid, canonical_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)
@@ -194,22 +203,46 @@ class CalDAVGateway:
) from None
return result
def _is_managed_event(self, event: Any) -> bool:
"""Détermine si un événement distant est géré par pronote-sync.
Vérifie la présence du marqueur de gestion (:data:`MANAGED_PROPERTY`
avec la valeur :data:`MANAGED_VALUE`) sur au moins un des composants
VEVENT de l'événement, selon le même motif que
:meth:`list_managed_events`.
:param event: Objet événement distant exposant la propriété
``icalendar_component`` retournant un ``icalendar.Calendar``.
:return: ``True`` si l'événement porte le marqueur de gestion,
``False`` sinon.
:rtype: bool
"""
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:
return True
return False
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.
``get_event_by_uid`` : s'il est introuvable (``NotFoundError``), un
nouvel événement est créé via ``add_event``. S'il existe, son contenu
est remplacé puis sauvegardé — uniquement si l'événement est géré par
l'outil (marqueur :data:`MANAGED_PROPERTY` avec la valeur
:data:`MANAGED_VALUE`). Un événement existant non géré provoque une
:class:`PronoteSyncError` explicite et n'est jamais modifié. Toute
autre exception est journalisée avec :func:`redact_exception` puis
re-levée en :class:`PronoteSyncError` — 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.
:raises PronoteSyncError: Si la passerelle n'est pas connectée, si
l'événement distant n'est pas géré par l'outil, ou si l'opération
échoue.
"""
if self._calendar is None:
raise PronoteSyncError("Passerelle CalDAV non connectée") from None
@@ -220,8 +253,14 @@ class CalDAVGateway:
except NotFoundError:
self._calendar.add_event(ical=vcalendar_text)
else:
if not self._is_managed_event(event):
raise PronoteSyncError(
"Conflit d'UID : l'événement distant n'est pas géré par pronote-sync"
) from None
event.data = vcalendar_text
event.save()
except PronoteSyncError:
raise
except Exception as exc:
error_msg = redact_exception(exc)
logger.error(
@@ -237,11 +276,15 @@ class CalDAVGateway:
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.
Récupère l'événement distant via ``get_event_by_uid`` : s'il est
introuvable (``NotFoundError``), la suppression est un succès
idempotent et la méthode retourne silencieusement. S'il existe, il
n'est supprimé que s'il est géré par l'outil (marqueur
:data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE`) ; un
événement non géré est laissé intact, un avertissement est
journalisé et la méthode retourne sans erreur. Toute autre exception
est journalisée avec :func:`redact_exception` et re-levée en
:class:`PronoteSyncError` — 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
@@ -252,7 +295,16 @@ class CalDAVGateway:
error_msg: str | None = None
try:
event = self._calendar.get_event_by_uid(uid)
if not self._is_managed_event(event):
logger.warning(
"Suppression refusée : l'événement distant UID=%s n'est pas géré par "
"pronote-sync",
redact_secrets(uid),
)
return
event.delete()
except NotFoundError:
return
except Exception as exc:
error_msg = redact_exception(exc)
logger.error(

View File

@@ -13,6 +13,7 @@ mais le résultat reflète les opérations qui auraient été effectuées.
from __future__ import annotations
import logging
from collections.abc import Mapping
from pronote_sync.errors import PronoteSyncError
from pronote_sync.models.agenda import Lesson, SchoolEvent
@@ -67,7 +68,12 @@ class CalDAVSyncExecutor:
self._gateway = gateway
self._dry_run = dry_run
def execute(self, plan: CalDAVSyncPlan) -> CalDAVSyncResult:
def execute(
self,
plan: CalDAVSyncPlan,
*,
remote_raw_by_canonical: Mapping[str, str] | None = None,
) -> 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
@@ -80,6 +86,10 @@ class CalDAVSyncExecutor:
(reprise idempotente), sinon ``SUCCESS``.
:param plan: Plan de synchronisation à appliquer.
:param remote_raw_by_canonical: Mapping canonical_uid -> raw_uid des
événements distants gérés, requis pour cibler l'UID brut lors des
mises à jour. ``None`` ou une clé absente entraîne une erreur
consignée dans ``result.errors`` pour chaque mise à jour concernée.
:return: Résultat de la synchronisation (statut, compteurs, erreurs).
:rtype: CalDAVSyncResult
"""
@@ -89,7 +99,12 @@ class CalDAVSyncExecutor:
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)
self._do_save(
lesson,
result,
is_update=True,
remote_raw_by_canonical=remote_raw_by_canonical,
)
for uid in plan.lessons_to_remove:
self._do_delete(uid, result)
@@ -97,7 +112,12 @@ class CalDAVSyncExecutor:
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)
self._do_save(
homework,
result,
is_update=True,
remote_raw_by_canonical=remote_raw_by_canonical,
)
for uid in plan.homeworks_to_remove:
self._do_delete(uid, result)
@@ -105,7 +125,12 @@ class CalDAVSyncExecutor:
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)
self._do_save(
school_event,
result,
is_update=True,
remote_raw_by_canonical=remote_raw_by_canonical,
)
for uid in plan.school_events_to_remove:
self._do_delete(uid, result)
@@ -123,6 +148,7 @@ class CalDAVSyncExecutor:
model: Lesson | Homework | SchoolEvent,
result: CalDAVSyncResult,
is_update: bool,
remote_raw_by_canonical: Mapping[str, str] | None = None,
) -> None:
"""Écrit un événement sur la passerelle, ou simule l'écriture.
@@ -133,12 +159,18 @@ class CalDAVSyncExecutor:
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``.
``result.errors``. Pour une mise à jour, l'UID cible est l'UID brut
distant (via ``remote_raw_by_canonical``) afin de mettre à jour le
vrai événement distant au lieu d'en créer un doublon ; si le mapping
est absent, l'erreur est consignée dans ``result.errors`` sans
interrompre le lot.
: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.
:param remote_raw_by_canonical: Mapping canonical_uid -> raw_uid des
événements distants gérés, utilisé uniquement pour les mises à jour.
"""
action = "mise à jour" if is_update else "ajout"
uid = _model_uid(model)
@@ -151,7 +183,15 @@ class CalDAVSyncExecutor:
return
try:
vcalendar_text = model_to_vcalendar_text(model)
self._gateway.upsert_event(vcalendar_text, uid)
target_uid = uid
if is_update:
if remote_raw_by_canonical is None or uid not in remote_raw_by_canonical:
result.errors.append(
f"UID canonique sans correspondant distant : {redact_secrets(uid)}"
)
return
target_uid = remote_raw_by_canonical[uid]
self._gateway.upsert_event(vcalendar_text, target_uid)
except PronoteSyncError as exc:
result.errors.append(redact_secrets(str(exc)))
return

View File

@@ -28,20 +28,32 @@ from pronote_sync.sync.serialization import (
def compute_plan(
pronote_data: PronoteData,
remote_managed: list[tuple[str, Any]],
) -> CalDAVSyncPlan:
"""Calcule le plan de synchronisation CalDAV.
remote_managed: list[tuple[str, str, Any]],
) -> tuple[CalDAVSyncPlan, dict[str, str]]:
"""Calcule le plan de synchronisation CalDAV et le mapping des UID distants.
L'appariement entre les événements locaux et distants se fait sur l'UID
canonique (forme normalisée, identique pour une même source Pronote,
suffixe temporel retiré) tandis que les mutations (suppressions, mises à
jour) ciblent l'UID brut tel que stocké sur le serveur. Le mapping
``canonical_uid -> raw_uid`` retourné permet à l'exécuteur de cibler le
bon objet distant lors des mises à jour.
: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
:param remote_managed: Liste de tuples (raw_uid, canonical_uid, vevent)
pour les événements distants marqués comme gérés par pronote-sync.
:return: Tuple (plan de synchronisation, mapping canonical_uid -> raw_uid).
Les listes ``*_to_remove`` contiennent l'UID brut distant, les autres
listes contiennent les modèles Pronote locaux.
:rtype: tuple[CalDAVSyncPlan, dict[str, str]]
"""
remote_signatures: dict[str, str] = {}
for uid, vevent in remote_managed:
remote_signatures[uid] = component_to_signature(vevent)
#: canonical_uid -> signature sémantique du VEVENT distant (pour l'appariement).
remote_signatures_by_canonical: dict[str, str] = {}
#: canonical_uid -> UID brut distant (pour cibler le bon objet lors des mutations).
remote_raw_by_canonical: dict[str, str] = {}
for raw_uid, canonical_uid, vevent in remote_managed:
remote_signatures_by_canonical[canonical_uid] = component_to_signature(vevent)
remote_raw_by_canonical[canonical_uid] = raw_uid
lessons_to_add: list[Lesson] = []
lessons_to_update: list[Lesson] = []
@@ -49,18 +61,12 @@ def compute_plan(
local_lessons_by_uid: dict[str, Lesson] = {lesson.id: lesson for lesson in pronote_data.lessons}
for lesson in pronote_data.lessons:
local_canonical = lesson.id
local_sig = component_to_signature(lesson_to_vevent(lesson))
if lesson.id not in remote_signatures:
if local_canonical not in remote_signatures_by_canonical:
lessons_to_add.append(lesson)
elif remote_signatures[lesson.id] != local_sig:
elif remote_signatures_by_canonical[local_canonical] != 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] = []
@@ -70,15 +76,12 @@ def compute_plan(
f"homework-{homework.id}": homework for homework in pronote_data.homeworks
}
for homework in pronote_data.homeworks:
uid = f"homework-{homework.id}"
local_canonical = f"homework-{homework.id}"
local_sig = component_to_signature(homework_to_vevent(homework))
if uid not in remote_signatures:
if local_canonical not in remote_signatures_by_canonical:
homeworks_to_add.append(homework)
elif remote_signatures[uid] != local_sig:
elif remote_signatures_by_canonical[local_canonical] != 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] = []
@@ -89,24 +92,39 @@ def compute_plan(
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_canonical = 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:
if local_canonical not in remote_signatures_by_canonical:
school_events_to_add.append(school_event)
elif remote_signatures[uid] != local_sig:
elif remote_signatures_by_canonical[local_canonical] != 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,
# Détection des événements distants orphelins : un UID canonique distant
# absent des données locales est supprimé en ciblant l'UID brut stocké sur
# le serveur. L'acheminement vers la bonne liste de suppression se fait sur
# le préfixe de l'UID canonique.
for canonical_uid in remote_signatures_by_canonical:
raw_uid = remote_raw_by_canonical[canonical_uid]
if canonical_uid.startswith("homework-"):
if canonical_uid not in local_homeworks_by_uid:
homeworks_to_remove.append(raw_uid)
elif canonical_uid.startswith("school-event-"):
if canonical_uid not in local_school_events_by_uid:
school_events_to_remove.append(raw_uid)
elif canonical_uid not in local_lessons_by_uid:
lessons_to_remove.append(raw_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,
),
remote_raw_by_canonical,
)

View File

@@ -21,6 +21,7 @@ from icalendar import Calendar, Component, Event, vDate, vDatetime
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.homework import Homework
from pronote_sync.utils.uid import normalize_datetime_to_utc
#: Propriété iCalendar marquant un événement géré par ``pronote-sync``.
MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED"
@@ -175,7 +176,10 @@ def component_to_signature(component: Component) -> str:
continue
value = getattr(raw, "dt", raw)
if hasattr(value, "isoformat"):
rendered = value.isoformat()
if isinstance(value, datetime):
rendered = normalize_datetime_to_utc(value).isoformat()
else:
rendered = value.isoformat()
else:
rendered = str(value).strip().lower()
props.append(f"{key.lower()}={rendered}")

View File

@@ -13,7 +13,7 @@ from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import datetime, timedelta
from datetime import datetime, time, timedelta
from typing import Any
from pronote_sync.config.settings import Settings
@@ -31,28 +31,69 @@ def synchronize(
pronote_data: PronoteData,
settings: Settings,
client_factory: Callable[..., Any] | None = None,
*,
now: datetime | 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).
La fenêtre de synchronisation est calculée en **journées complètes** :
elle commence à minuit de ``aujourd'hui - sync_past_days`` (inclusive) et
se termine, de façon exclusive, à minuit de ``aujourd'hui +
sync_future_days + 1``, afin que le dernier jour de la fenêtre soit couvert
en entier. Cette fenêtre s'applique aux **deux** côtés de la
synchronisation : les événements distants gérés scannés sur la passerelle
et les données Pronote locales filtrées (cours filtrés sur ``start``,
devoirs sur ``due_on`` en jour, événements scolaires sur le chevauchement
de leur période) avant d'être transmises au planificateur. 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
l'instant de référence par défaut, remplaçable via ``now`` pour les tests.
: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).
:param now: Instant de référence pour le calcul de la fenêtre ; par défaut
``datetime.now()``.
: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)
now = now or datetime.now()
window_start = datetime.combine(
(now - timedelta(days=settings.app.sync_past_days)).date(),
time(0, 0),
)
window_end = datetime.combine(
(now + timedelta(days=settings.app.sync_future_days + 1)).date(),
time(0, 0),
)
# Application de la fenêtre aux données Pronote locales avant le passage au
# planificateur : les événements hors fenêtre ne doivent être ni écrits sur
# le calendrier ni déclencher de suppression d'un événement distant géré.
filtered_lessons = [
lesson for lesson in pronote_data.lessons if window_start <= lesson.start < window_end
]
filtered_homeworks = [
hw for hw in pronote_data.homeworks if window_start.date() <= hw.due_on < window_end.date()
]
filtered_school_events = [
se
for se in pronote_data.school_events
if se.from_date < window_end.date() and se.to_date > window_start.date()
]
filtered_pronote = PronoteData(
lessons=filtered_lessons,
homeworks=filtered_homeworks,
school_events=filtered_school_events,
messages=pronote_data.messages,
target_date=pronote_data.target_date,
generated_at=pronote_data.generated_at,
)
if (
settings.caldav.url is None
@@ -65,13 +106,13 @@ def synchronize(
gateway = CalDAVGateway(settings.caldav, client_factory=client_factory)
try:
with gateway:
remote_managed = gateway.list_managed_events(start=start, end=end)
remote_managed = gateway.list_managed_events(start=window_start, end=window_end)
logger.info(
"Synchronisation CalDAV : %d événements distants gérés trouvés",
len(remote_managed),
)
plan = compute_plan(pronote_data, remote_managed)
plan, remote_raw_by_canonical = compute_plan(filtered_pronote, remote_managed)
n_add = (
len(plan.lessons_to_add)
+ len(plan.homeworks_to_add)
@@ -97,7 +138,7 @@ def synchronize(
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)
return executor.execute(plan, remote_raw_by_canonical=remote_raw_by_canonical)
except PronoteSyncError:
logger.error("Échec de la synchronisation CalDAV")
# Re-lève la même exception de domaine sans en créer de nouvelle.

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib
import re
from datetime import datetime
from zoneinfo import ZoneInfo
_TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$")
_EDUCATION_SUFFIX_PATTERN = re.compile(r"-Index-Education$")
@@ -31,6 +32,21 @@ def normalize_pronote_uid(uid: str) -> str:
return _EDUCATION_SUFFIX_PATTERN.sub("", normalized)
def normalize_datetime_to_utc(dt: datetime) -> datetime:
"""Normalise une datetime vers UTC pour les signatures et hachages.
Les datetimes naïves sont interprétées comme Europe/Paris puis converties
vers UTC. Les datetimes conscientes sont converties vers UTC.
:param dt: Datetime à normaliser (naïve ou consciente).
:return: Datetime en UTC.
:rtype: datetime
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=ZoneInfo("Europe/Paris")).astimezone(ZoneInfo("UTC"))
return dt.astimezone(ZoneInfo("UTC"))
def generate_deterministic_uid(
start: datetime,
end: datetime,
@@ -56,8 +72,8 @@ def generate_deterministic_uid(
:rtype: str
"""
parts = [
start.isoformat(),
end.isoformat(),
normalize_datetime_to_utc(start).isoformat(),
normalize_datetime_to_utc(end).isoformat(),
subject,
",".join(sorted(teachers)),
",".join(sorted(rooms)),

View File

@@ -85,13 +85,50 @@ class FakeCalendar:
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
def search(
self,
*,
start: datetime | None = None,
end: datetime | None = None,
event: bool = True,
expand: bool = True,
**kwargs: Any,
) -> list[FakeCalendarEvent]:
"""Retourne tous les événements comme de faux objets calendrier."""
"""Retourne les événements dont la période chevauche la fenêtre donnée.
Reproduit le comportement d'un vrai serveur CalDAV : seuls les
événements dont la période chevauche la fenêtre ``[start, end)`` sont
retournés, comme de faux objets calendrier.
:param start: Début de la fenêtre de recherche (``None`` : pas de
borne inférieure).
:param end: Fin de la fenêtre de recherche (``None`` : pas de borne
supérieure).
:param event: Non utilisé (le faux ne traite que des VEVENT).
:param expand: Non utilisé (compatibilité avec la passerelle).
:param kwargs: Paramètres additionnels ignorés (compatibilité avec
l'API ``caldav``).
:return: Liste des événements chevauchant la fenêtre.
:rtype: list[FakeCalendarEvent]
"""
results: list[FakeCalendarEvent] = []
for uid, ical_text in self._server._events.items():
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
cal = Calendar.from_ical(ical_text)
for component in cal.walk("VEVENT"):
raw_start = component.get("dtstart")
raw_end = component.get("dtend")
if raw_start is None:
continue
event_start = raw_start.dt
event_end = raw_end.dt if raw_end is not None else event_start
overlaps = True
if start is not None:
overlaps = overlaps and event_end > start
if end is not None:
overlaps = overlaps and event_start < end
if overlaps:
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
break
return results
def get_event_by_uid(self, uid: str) -> FakeCalendarEvent:
@@ -266,6 +303,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -308,6 +346,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result1.added == 1
@@ -316,6 +355,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result2.status == CalDAVSyncStatus.SKIPPED
assert result2.added == 0
@@ -366,6 +406,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications : le résultat indique un ajout, mais le serveur reste vide
@@ -415,6 +456,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -454,6 +496,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -496,6 +539,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -538,6 +582,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications : l'événement non géré est toujours présent
@@ -593,6 +638,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -632,6 +678,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -667,6 +714,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 12, 20, 12, 0),
)
# Vérifications
@@ -758,6 +806,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -770,3 +819,111 @@ class TestCalDAVSynchronize:
events = fake_caldav_server.get_events()
assert "UNMANAGED-001" in events
assert len(events) == 4 # L-001 (mis à jour), L-002, hw-002, UNMANAGED-001
def test_sync_filters_out_of_window_events(
self,
fake_caldav_server: FakeCalDAVServer,
full_settings: Settings,
) -> None:
"""Teste qu'un cours hors fenêtre n'est pas écrit sur le calendrier.
Avec ``now=2026-01-14`` et la configuration par défaut (7 jours dans
le passé, 30 dans le futur), la fenêtre couvre les journées complètes
du 2026-01-07 au 2026-02-14 (fin exclusive) : un cours le 2026-02-15
doit être filtré et ne jamais atteindre le planificateur ni le
calendrier.
"""
# Données Pronote avec un cours hors de la fenêtre
pronote_data = PronoteData(
lessons=[
Lesson(
id="L-001",
start=datetime(2026, 2, 15, 8, 0),
end=datetime(2026, 2, 15, 9, 0),
subject="Mathématiques",
teachers=("Prof Dupont",),
rooms=("Salle 101",),
status=LessonStatus.NORMAL,
group=None,
content=None,
)
],
homeworks=[],
school_events=[],
messages=[],
target_date=datetime(2026, 2, 15).date(),
generated_at=datetime(2026, 2, 15, 0, 0),
)
# Synchronisation
result = synchronize(
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications : le cours hors fenêtre n'est jamais écrit
assert result.status == CalDAVSyncStatus.SKIPPED
assert result.added == 0
assert result.updated == 0
assert result.removed == 0
assert len(fake_caldav_server.get_events()) == 0
def test_sync_idempotent_at_window_edge(
self,
fake_caldav_server: FakeCalDAVServer,
full_settings: Settings,
) -> None:
"""Teste l'idempotence pour un cours tardif en fin de fenêtre.
Un cours à 23:00 le dernier jour complet de la fenêtre (2026-02-13 ;
la fenêtre se termine de façon exclusive le 2026-02-14) doit être
ajouté une seule fois : une seconde exécution identique doit le
détecter comme déjà présent et ne rien réécrire.
"""
# Données Pronote avec un cours tardif le dernier jour de la fenêtre
pronote_data = PronoteData(
lessons=[
Lesson(
id="L-001",
start=datetime(2026, 2, 13, 23, 0),
end=datetime(2026, 2, 13, 23, 30),
subject="Mathématiques",
teachers=("Prof Dupont",),
rooms=("Salle 101",),
status=LessonStatus.NORMAL,
group=None,
content=None,
)
],
homeworks=[],
school_events=[],
messages=[],
target_date=datetime(2026, 2, 13).date(),
generated_at=datetime(2026, 2, 13, 0, 0),
)
# Première synchronisation : le cours est ajouté
result1 = synchronize(
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result1.status == CalDAVSyncStatus.SUCCESS
assert result1.added == 1
assert len(fake_caldav_server.get_events()) == 1
# Seconde synchronisation identique : rien n'est réécrit
result2 = synchronize(
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result2.status == CalDAVSyncStatus.SKIPPED
assert result2.added == 0
assert result2.updated == 0
assert result2.removed == 0
assert len(fake_caldav_server.get_events()) == 1

View File

@@ -339,12 +339,69 @@ def test_real_update_calls_gateway(
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_lesson_update)
result = executor.execute(
plan_with_lesson_update,
remote_raw_by_canonical={"L-1234": "L-1234"},
)
mock_gateway.upsert_event.assert_called_once()
assert result.updated == 1
def test_update_uses_raw_uid_from_mapping(
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
) -> None:
"""Vérifie que la mise à jour cible l'UID brut distant du mapping.
Pour une mise à jour, l'exécuteur doit appeler ``upsert_event`` avec
l'UID brut fourni par ``remote_raw_by_canonical`` (celui stocké sur le
serveur) et non l'UID canonique, afin d'éviter la création d'un doublon.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
:return: None
"""
raw_uid = "L-1234-20260905T080000Z-Index-Education"
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(
plan_with_lesson_update,
remote_raw_by_canonical={"L-1234": raw_uid},
)
call_args = mock_gateway.upsert_event.call_args
assert call_args[0][0] is not None
# Le contenu VEVENT porte l'UID canonique du modèle.
assert "UID:L-1234" in call_args[0][0]
# La cible (2e argument) est l'UID brut distant, pas l'UID canonique.
assert call_args[0][1] == raw_uid
assert result.updated == 1
assert len(result.errors) == 0
def test_update_without_mapping_raises_error(
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
) -> None:
"""Vérifie qu'une mise à jour sans mapping distant consigne une erreur.
Si ``remote_raw_by_canonical`` est absent (ou sans clé pour l'UID
canonique), l'exécuteur ne doit pas retomber silencieusement sur l'UID
canonique (créant un doublon) : l'erreur est consignée dans
``result.errors`` et le lot continue sans appeler la passerelle.
:param mock_gateway: Passerelle mockée.
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
:return: None
"""
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan_with_lesson_update)
mock_gateway.upsert_event.assert_not_called()
assert len(result.errors) == 1
assert "UID canonique sans correspondant distant" in result.errors[0]
assert result.updated == 0
assert result.status == CalDAVSyncStatus.FAILED
def test_real_delete_calls_gateway(
mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan
) -> None:
@@ -380,7 +437,10 @@ def test_real_execution_all_operations(
)
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
result = executor.execute(plan)
result = executor.execute(
plan,
remote_raw_by_canonical={"L-0002": "L-0002"},
)
# upsert_event appelé pour les ajouts et mises à jour
# 1 lesson add + 1 lesson update + 1 homework add + 1 school event add = 4

View File

@@ -7,6 +7,7 @@ avec une attention particulière à la sécurité (masquage des secrets).
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
@@ -22,7 +23,7 @@ from pronote_sync.sync.caldav import CalDAVGateway
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
if TYPE_CHECKING:
pass
from _pytest.logging import LogCaptureFixture
# --- Fixtures ---
@@ -312,7 +313,7 @@ def test_list_managed_events_returns_only_managed(
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]
mock_calendar.search.return_value = [mock_managed_event, mock_unmanaged_event]
start = datetime(2026, 1, 1)
end = datetime(2026, 12, 31)
@@ -320,8 +321,9 @@ def test_list_managed_events_returns_only_managed(
# Seuls les événements gérés doivent être retournés
assert len(result) == 1
uid, vevent = result[0]
assert uid == "test-uid-123"
raw_uid, canonical_uid, vevent = result[0]
assert raw_uid == "test-uid-123"
assert canonical_uid == "test-uid-123" # Pas de suffixe Pronote : canonique == brut
assert str(vevent.get("UID")) == "test-uid-123"
@@ -359,7 +361,7 @@ def test_list_managed_events_caldav_error_raises(
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0
]
mock_calendar.date_search.side_effect = Exception("CalDAV error")
mock_calendar.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))
@@ -402,11 +404,13 @@ def test_upsert_event_creates_new_event_when_uid_missing(
def test_upsert_event_updates_existing_event_by_uid(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_managed_event: MagicMock,
) -> None:
"""Vérifie que upsert_event remplace le contenu d'un événement existant.
"""Vérifie que upsert_event remplace le contenu d'un événement géré existant.
: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é.
:return: None
"""
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
@@ -415,16 +419,15 @@ def test_upsert_event_updates_existing_event_by_uid(
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
mock_calendar.get_event_by_uid.return_value = mock_managed_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()
assert mock_managed_event.data == vcalendar_text
mock_managed_event.save.assert_called_once()
mock_calendar.add_event.assert_not_called()
@@ -474,30 +477,66 @@ def test_upsert_event_caldav_error_raises(
# --- delete_event tests ---
def test_delete_event_calls_calendar(
def test_upsert_event_refuses_unmanaged_event(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_unmanaged_event: MagicMock,
) -> None:
"""Vérifie que delete_event appelle event.delete() sur l'événement trouvé.
"""Vérifie que upsert_event refuse de modifier un événement non géré.
Un événement distant existant sans le marqueur de gestion ne doit jamais
être écrasé : la méthode lève PronoteSyncError avec un message « Conflit »
et n'appelle pas ``add_event``.
:param caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients mockée.
: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 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
mock_calendar.get_event_by_uid.return_value = mock_unmanaged_event
vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR"
with pytest.raises(PronoteSyncError) as exc_info:
gateway.upsert_event(vcalendar_text, "test-123")
assert "Conflit" in str(exc_info.value)
mock_calendar.get_event_by_uid.assert_called_once_with("test-123")
mock_calendar.add_event.assert_not_called()
mock_unmanaged_event.save.assert_not_called()
def test_delete_event_calls_calendar(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_managed_event: MagicMock,
) -> None:
"""Vérifie que delete_event appelle event.delete() sur un événement géré.
: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é.
:return: None
"""
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
gateway.connect()
# Configurer le calendrier pour retourner un événement géré mocké
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0
]
mock_calendar.get_event_by_uid.return_value = mock_managed_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()
mock_managed_event.delete.assert_called_once()
def test_delete_event_not_connected_raises(
@@ -543,6 +582,66 @@ def test_delete_event_caldav_error_raises(
assert exc_info.value.__context__ is None
def test_delete_event_refuses_unmanaged_event(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_unmanaged_event: MagicMock,
caplog: LogCaptureFixture,
) -> None:
"""Vérifie que delete_event refuse de supprimer un événement non géré.
L'événement distant sans le marqueur de gestion ne doit jamais être
supprimé : la méthode retourne sans erreur, journalise un avertissement
et n'appelle pas ``event.delete()``.
:param caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients mockée.
:param mock_unmanaged_event: Événement non géré mocké.
:param caplog: Capture des journaux pytest.
: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.return_value = mock_unmanaged_event
with caplog.at_level(logging.WARNING):
gateway.delete_event("test-uid-123")
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
mock_unmanaged_event.delete.assert_not_called()
assert any("Suppression refusée" in record.getMessage() for record in caplog.records)
def test_delete_event_idempotent_when_not_found(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
) -> None:
"""Vérifie que delete_event est idempotent quand l'UID est introuvable.
Une suppression d'un événement déjà absent est un succès silencieux :
aucune exception n'est levée.
: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")
gateway.delete_event("test-uid-123")
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
# --- Context manager tests ---

View File

@@ -183,9 +183,9 @@ def test_lesson_add_when_not_in_remote() -> None:
"""
lesson = _make_lesson(lesson_id="L-1234")
pronote_data = _make_pronote_data(lessons=[lesson])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 1
assert plan.lessons_to_add[0].id == "L-1234"
@@ -208,9 +208,9 @@ def test_lesson_update_when_signature_differs() -> None:
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 1
@@ -228,9 +228,9 @@ def test_lesson_idempotent_when_signature_same() -> None:
# 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)]
remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
@@ -250,9 +250,9 @@ def test_lesson_remove_when_not_in_local() -> None:
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [("L-9999", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [("L-9999", "L-9999", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
@@ -260,6 +260,62 @@ def test_lesson_remove_when_not_in_local() -> None:
assert plan.lessons_to_remove[0] == "L-9999"
def test_plan_with_suffixed_remote_uid_matches_canonical() -> None:
"""Vérifie qu'un UID distant suffixé apparié par UID canonique ne produit rien.
L'événement distant porte un UID brut suffixé
(``L-1234-20260905T080000Z-Index-Education``) dont la forme canonique
(``L-1234``) correspond au cours local ; les signatures étant identiques,
le plan doit être vide — sans ajout, mise à jour ou suppression artificiels.
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
pronote_data = _make_pronote_data(lessons=[lesson])
# VEVENT distant construit à partir du même cours : contenu sémantique
# identique (seul l'UID brut stocké diffère, capturé par le triplet).
remote_event = lesson_to_vevent(lesson)
remote_managed: list[tuple[str, str, Event]] = [
("L-1234-20260905T080000Z-Index-Education", "L-1234", remote_event)
]
plan, raw_mapping = 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_plan_with_suffixed_remote_uid_and_no_local_adds_to_remove() -> None:
"""Vérifie que la suppression d'un UID distant suffixé utilise l'UID brut.
Un événement distant orphelin (aucun cours local) dont l'UID brut est
suffixé doit être supprimé en ciblant l'UID brut stocké sur le serveur,
et non sa forme canonique.
:return: None
"""
pronote_data = _make_pronote_data(lessons=[])
raw_uid = "L-9999-20260101T080000Z-Index-Education"
remote_event = _make_vevent(
uid=raw_uid,
summary="Ancien cours",
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, str, Event]] = [(raw_uid, "L-9999", remote_event)]
plan, raw_mapping = 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
# La liste de suppression contient l'UID brut, pas la forme canonique.
assert plan.lessons_to_remove[0] == raw_uid
def test_lesson_cancelled_preserved() -> None:
"""Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour).
@@ -267,9 +323,9 @@ def test_lesson_cancelled_preserved() -> None:
"""
lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED)
pronote_data = _make_pronote_data(lessons=[lesson])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = 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
@@ -287,9 +343,9 @@ def test_homework_add_when_not_in_remote() -> None:
"""
homework = _make_homework(homework_id="HW-5678")
pronote_data = _make_pronote_data(homeworks=[homework])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 1
assert plan.homeworks_to_add[0].id == "HW-5678"
@@ -312,9 +368,11 @@ def test_homework_update_when_signature_differs() -> None:
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)]
remote_managed: list[tuple[str, str, Event]] = [
("homework-HW-5678", "homework-HW-5678", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 0
assert len(plan.homeworks_to_update) == 1
@@ -335,9 +393,11 @@ def test_homework_remove_when_not_in_local() -> None:
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)]
remote_managed: list[tuple[str, str, Event]] = [
("homework-HW-9999", "homework-HW-9999", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 0
assert len(plan.homeworks_to_update) == 0
@@ -358,9 +418,9 @@ def test_school_event_add_when_not_in_remote() -> None:
from_date=date(2026, 12, 20),
)
pronote_data = _make_pronote_data(school_events=[school_event])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = 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"
@@ -390,11 +450,15 @@ def test_school_event_update_when_signature_differs() -> None:
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)
remote_managed: list[tuple[str, str, Event]] = [
(
"school-event-Vacances de Noël-2026-12-20",
"school-event-Vacances de Noël-2026-12-20",
remote_event,
)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 0
assert len(plan.school_events_to_update) == 1
@@ -415,11 +479,15 @@ def test_school_event_remove_when_not_in_local() -> None:
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)
remote_managed: list[tuple[str, str, Event]] = [
(
"school-event-Ancien événement-2026-01-01",
"school-event-Ancien événement-2026-01-01",
remote_event,
)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 0
assert len(plan.school_events_to_update) == 0
@@ -443,9 +511,11 @@ def test_uid_routing_homework_to_remove() -> None:
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)]
remote_managed: list[tuple[str, str, Event]] = [
("homework-HW-9999", "homework-HW-9999", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0
@@ -466,9 +536,11 @@ def test_uid_routing_school_event_to_remove() -> None:
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)]
remote_managed: list[tuple[str, str, Event]] = [
("school-event-Vacances-2026-12-20", "school-event-Vacances-2026-12-20", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0
@@ -485,9 +557,9 @@ def test_empty_inputs_empty_plan() -> None:
:return: None
"""
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
@@ -535,12 +607,12 @@ def test_mixed_scenario() -> None:
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [
("L-0002", remote_lesson2),
("L-0003", remote_lesson3),
remote_managed: list[tuple[str, str, Event]] = [
("L-0002", "L-0002", remote_lesson2),
("L-0003", "L-0003", remote_lesson3),
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Ajouts
assert len(plan.lessons_to_add) == 1
@@ -570,9 +642,9 @@ def test_unmanaged_events_not_in_remote_managed() -> None:
# 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]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Le cours doit être dans lessons_to_add
assert len(plan.lessons_to_add) == 1

View File

@@ -257,7 +257,7 @@ class TestCalDAVGatewaySecurity:
mock_principal = MagicMock()
mock_calendar = MagicMock()
mock_calendar.url = "https://caldav.example.com/cal/"
mock_calendar.date_search.side_effect = Exception(
mock_calendar.search.side_effect = Exception(
f"Search failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}"
)
mock_principal.calendars.return_value = [mock_calendar]