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", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true, "is_verified": true,
"line_number": 5062, "line_number": 4940,
"is_secret": false "is_secret": false
} }
], ],
@@ -150,14 +150,14 @@
"filename": "tests/unit/test_caldav_gateway.py", "filename": "tests/unit/test_caldav_gateway.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false, "is_verified": false,
"line_number": 151 "line_number": 152
}, },
{ {
"type": "Basic Auth Credentials", "type": "Basic Auth Credentials",
"filename": "tests/unit/test_caldav_gateway.py", "filename": "tests/unit/test_caldav_gateway.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false, "is_verified": false,
"line_number": 664 "line_number": 763
} }
], ],
"tests/unit/test_caldav_security.py": [ "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 │ │ SYNCHRONISATION CALDAV │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────────┐ │
│ │ Plan de sync │ │ Sync │ │ État local │ │
│ │ (CalDavSyncPlan)│ │ différentielle │ │ (SQLite/JSON) │ │
│ └────────┬────────┘ └────────┬────────┘ └──────────────┬────────────┘ │
│ │ │ │ │ │ │ │ │ │
│ └───────────────────────┼────────────────────────────┘ │ │ └───────────────────────┼────────────────────────────┘ │
│ ▼ │ │ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ Résultat de sync (CalDavSyncResult) │ │ │ │ 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 │ │ └── fallback.py # Logique de repli
│ ├── blog/ # Blog (RSS) │ ├── blog/ # Blog (RSS)
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ── rss.py # Client RSS (feedparser) │ │ ── rss.py # Client RSS (feedparser)
│ │ └── state.py # État local (déduplication, cache HTTP)
│ └── theoretical/ # Agenda théorique │ └── theoretical/ # Agenda théorique
│ ├── __init__.py │ ├── __init__.py
│ ├── file.py # Lecture fichier JSON (parité + vacances) │ ├── file.py # Lecture fichier JSON (parité + vacances)
@@ -211,8 +209,6 @@ pronote_sync/
├── sync/ # Synchronisation CalDAV + Blog ├── sync/ # Synchronisation CalDAV + Blog
│ ├── __init__.py │ ├── __init__.py
│ ├── caldav.py # Client CalDAV (caldav) │ ├── 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 │ └── diff.py # Logique de comparaison
├── synthesis/ # Synthèse IA ├── synthesis/ # Synthèse IA
│ ├── __init__.py │ ├── __init__.py
@@ -1070,135 +1066,18 @@ class BlogRSSClient:
return text 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). La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si le GUID est vide).
**Stratégie** : **Stratégie** :
1. Stocker un **fichier d'état local** (ex: `.blog_rss_state.json`) contenant la version du - Conserver en mémoire, **au sein du run**, l'ensemble des GUID déjà traités pour la déduplication.
format, l'**ensemble des GUID déjà traités** et les en-têtes de cache HTTP (`ETag` / - Utiliser le **cache HTTP** (`ETag` / `Last-Modified`) via `feedparser` pour éviter les requêtes inutiles.
`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.
**Exemple de fichier d'état** : Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run.
```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
# Initialisation # Initialisation
rss_client = BlogRSSClient(rss_url=settings.blog.rss_url) 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 # Récupération des nouveaux articles
known_guids = blog_state.get_known_guids() known_guids = blog_state.get_known_guids()
@@ -1224,12 +1103,12 @@ blog_state.update_cache_headers(result.etag, result.last_modified)
List List
from ..models.blog import BlogArticle from ..models.blog import BlogArticle
from ..sources.blog.rss import BlogRSSClient 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( def fetch_blog_step(
rss_client: BlogRSSClient, rss_client: BlogRSSClient,
blog_state: BlogRSSState, blog_state: ## (section obsolète supprimée),
enabled: bool = False, enabled: bool = False,
) -> List[BlogArticle]: ) -> List[BlogArticle]:
""" """
@@ -1430,11 +1309,11 @@ def test_parse_blog_rss(mock_blog_rss_client):
@pytest.mark.unittest @pytest.mark.unittest
def test_blog_deduplication(tmp_path): def test_blog_deduplication(tmp_path):
"""Test la déduplication des articles du blog.""" """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 # Créer un fichier d'état temporaire
state_file = tmp_path / "blog_state.json" 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 # Initialement, aucun article connu
assert state.get_known_guids() == frozenset() assert state.get_known_guids() == frozenset()
@@ -3300,9 +3179,8 @@ class CalDAVClient:
sauvegardes). sauvegardes).
> **Évolution future** : si les performances l'exigent (calendrier très chargé, scans > **É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 > trop coûteux), un **état local** (sync-token CalDAV) pourra être ajouté dans un jalon
> être ajouté dans un jalon ultérieur, sans changer le contrat de la synchronisation > ultérieur, sans changer le contrat de la synchronisation (§7.1, §7.2 et §7.4 restent valables).
> (§7.1, §7.2 et §7.4 restent valables).
### 7.4 Points clés ### 7.4 Points clés
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser. - **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
@@ -4606,7 +4484,7 @@ class PipelineRunner:
synthesis_provider: Optional[SynthesisProvider], synthesis_provider: Optional[SynthesisProvider],
channel: Channel, channel: Channel,
blog_rss_client: Optional["BlogRSSClient"] = None, blog_rss_client: Optional["BlogRSSClient"] = None,
blog_state: Optional["BlogRSSState"] = None, blog_state: Optional["## (section obsolète supprimée)"] = None,
dry_run: bool = False, dry_run: bool = False,
): ):
self.pronote_fetcher = pronote_fetcher 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.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient 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 from .errors import PipelineError, ErrorSeverity
def fetch_blog_step( def fetch_blog_step(
rss_client: BlogRSSClient, rss_client: BlogRSSClient,
blog_state: BlogRSSState, blog_state: ## (section obsolète supprimée),
enabled: bool = False, enabled: bool = False,
) -> list[BlogArticle]: ) -> 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 | | 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 | | 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 | | 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 ### 13.2 Validation des entrées
| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** | | **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 | | 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 | | 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 | | 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. | | **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`. | | **`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. | | **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. | | **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. | | **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. | | **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. - 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 ## M7. Synchronisation CalDAV — Priorité : Haute
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente. 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`). - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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). - [ ] 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`. - [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
### Critères d'acceptation ### Critères d'acceptation
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés). - 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 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. - 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.errors import PronoteSyncError
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE 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.redaction import redact_exception, redact_secrets, redact_url
from pronote_sync.utils.uid import normalize_pronote_uid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -158,33 +159,41 @@ class CalDAVGateway:
if error_msg is not None: if error_msg is not None:
raise PronoteSyncError(f"Échec de la connexion CalDAV : {self._redacted_url}") from 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. """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 Interroge le serveur CalDAV sur la fenêtre ``[start, end]`` et ne
conserve que les VEVENT portant le marqueur de gestion conserve que les VEVENT portant le marqueur de gestion
(:data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE`). (: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 start: Début de la fenêtre de recherche.
:param end: Fin 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é. :return: Triplets ``(raw_uid, canonical_uid, vevent)`` pour chaque
:rtype: list[tuple[str, Any]] événement géré trouvé.
:rtype: list[tuple[str, str, Any]]
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si :raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
la récupération échoue. la récupération échoue.
""" """
if self._calendar is None: if self._calendar is None:
raise PronoteSyncError("Passerelle CalDAV non connectée") from 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 error_msg: str | None = None
try: 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: for event in events:
component: Component = event.icalendar_component component: Component = event.icalendar_component
for vevent in component.walk("VEVENT"): for vevent in component.walk("VEVENT"):
managed = vevent.get(MANAGED_PROPERTY) managed = vevent.get(MANAGED_PROPERTY)
if managed is not None and str(managed) == MANAGED_VALUE: if managed is not None and str(managed) == MANAGED_VALUE:
uid = str(vevent.get("UID")) raw_uid = str(vevent.get("UID"))
result.append((uid, vevent)) canonical_uid = normalize_pronote_uid(raw_uid)
result.append((raw_uid, canonical_uid, vevent))
except Exception as exc: except Exception as exc:
error_msg = redact_exception(exc) error_msg = redact_exception(exc)
logger.error("Échec de la récupération des événements CalDAV : %s", error_msg) logger.error("Échec de la récupération des événements CalDAV : %s", error_msg)
@@ -194,22 +203,46 @@ class CalDAVGateway:
) from None ) from None
return result 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: def upsert_event(self, vcalendar_text: str, uid: str) -> None:
"""Crée ou met à jour un événement CalDAV identifié par son UID. """Crée ou met à jour un événement CalDAV identifié par son UID.
Recherche d'abord l'événement existant par UID via Recherche d'abord l'événement existant par UID via
``get_event_by_uid`` : s'il existe, son contenu est remplacé puis ``get_event_by_uid`` : s'il est introuvable (``NotFoundError``), un
sauvegardé ; s'il est introuvable (``NotFoundError``), un nouvel nouvel événement est créé via ``add_event``. S'il existe, son contenu
événement est créé via ``add_event``. Toute autre exception est est remplacé puis sauvegardé — uniquement si l'événement est géré par
journalisée avec :func:`redact_exception` puis re-levée en l'outil (marqueur :data:`MANAGED_PROPERTY` avec la valeur
:class:`PronoteSyncError` — hors du bloc ``except``, afin que :data:`MANAGED_VALUE`). Un événement existant non géré provoque une
``__context__`` ne retienne aucune exception brute — sans chaînage ni :class:`PronoteSyncError` explicite et n'est jamais modifié. Toute
donnée sensible. 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 vcalendar_text: Document iCalendar complet (VCALENDAR + VEVENT).
:param uid: UID stable de l'événement à créer ou mettre à jour. :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 :raises PronoteSyncError: Si la passerelle n'est pas connectée, si
l'opération échoue. l'événement distant n'est pas géré par l'outil, ou si l'opération
échoue.
""" """
if self._calendar is None: if self._calendar is None:
raise PronoteSyncError("Passerelle CalDAV non connectée") from None raise PronoteSyncError("Passerelle CalDAV non connectée") from None
@@ -220,8 +253,14 @@ class CalDAVGateway:
except NotFoundError: except NotFoundError:
self._calendar.add_event(ical=vcalendar_text) self._calendar.add_event(ical=vcalendar_text)
else: 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.data = vcalendar_text
event.save() event.save()
except PronoteSyncError:
raise
except Exception as exc: except Exception as exc:
error_msg = redact_exception(exc) error_msg = redact_exception(exc)
logger.error( logger.error(
@@ -237,11 +276,15 @@ class CalDAVGateway:
def delete_event(self, uid: str) -> None: def delete_event(self, uid: str) -> None:
"""Supprime un événement du calendrier, identifié par son UID. """Supprime un événement du calendrier, identifié par son UID.
Récupère l'événement distant via ``get_event_by_uid`` puis le Récupère l'événement distant via ``get_event_by_uid`` : s'il est
supprime. Toute exception est journalisée avec introuvable (``NotFoundError``), la suppression est un succès
:func:`redact_exception` et re-levée en :class:`PronoteSyncError` — idempotent et la méthode retourne silencieusement. S'il existe, il
hors du bloc ``except``, afin que ``__context__`` ne retienne aucune n'est supprimé que s'il est géré par l'outil (marqueur
exception brute — sans chaînage ni donnée sensible. :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. :param uid: Identifiant UID de l'événement à supprimer.
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si :raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
@@ -252,7 +295,16 @@ class CalDAVGateway:
error_msg: str | None = None error_msg: str | None = None
try: try:
event = self._calendar.get_event_by_uid(uid) 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() event.delete()
except NotFoundError:
return
except Exception as exc: except Exception as exc:
error_msg = redact_exception(exc) error_msg = redact_exception(exc)
logger.error( 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 from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from pronote_sync.errors import PronoteSyncError from pronote_sync.errors import PronoteSyncError
from pronote_sync.models.agenda import Lesson, SchoolEvent from pronote_sync.models.agenda import Lesson, SchoolEvent
@@ -67,7 +68,12 @@ class CalDAVSyncExecutor:
self._gateway = gateway self._gateway = gateway
self._dry_run = dry_run 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. """Exécute le plan de synchronisation et retourne le résultat.
Les cours, devoirs et événements scolaires sont traités dans l'ordre Les cours, devoirs et événements scolaires sont traités dans l'ordre
@@ -80,6 +86,10 @@ class CalDAVSyncExecutor:
(reprise idempotente), sinon ``SUCCESS``. (reprise idempotente), sinon ``SUCCESS``.
:param plan: Plan de synchronisation à appliquer. :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). :return: Résultat de la synchronisation (statut, compteurs, erreurs).
:rtype: CalDAVSyncResult :rtype: CalDAVSyncResult
""" """
@@ -89,7 +99,12 @@ class CalDAVSyncExecutor:
for lesson in plan.lessons_to_add: for lesson in plan.lessons_to_add:
self._do_save(lesson, result, is_update=False) self._do_save(lesson, result, is_update=False)
for lesson in plan.lessons_to_update: 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: for uid in plan.lessons_to_remove:
self._do_delete(uid, result) self._do_delete(uid, result)
@@ -97,7 +112,12 @@ class CalDAVSyncExecutor:
for homework in plan.homeworks_to_add: for homework in plan.homeworks_to_add:
self._do_save(homework, result, is_update=False) self._do_save(homework, result, is_update=False)
for homework in plan.homeworks_to_update: 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: for uid in plan.homeworks_to_remove:
self._do_delete(uid, result) self._do_delete(uid, result)
@@ -105,7 +125,12 @@ class CalDAVSyncExecutor:
for school_event in plan.school_events_to_add: for school_event in plan.school_events_to_add:
self._do_save(school_event, result, is_update=False) self._do_save(school_event, result, is_update=False)
for school_event in plan.school_events_to_update: 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: for uid in plan.school_events_to_remove:
self._do_delete(uid, result) self._do_delete(uid, result)
@@ -123,6 +148,7 @@ class CalDAVSyncExecutor:
model: Lesson | Homework | SchoolEvent, model: Lesson | Homework | SchoolEvent,
result: CalDAVSyncResult, result: CalDAVSyncResult,
is_update: bool, is_update: bool,
remote_raw_by_canonical: Mapping[str, str] | None = None,
) -> None: ) -> None:
"""Écrit un événement sur la passerelle, ou simule l'écriture. """É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 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 création ou la mise à jour de l'événement) ; en cas d'erreur
``PronoteSyncError``, le message expurgé est ajouté à ``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 model: Modèle Pronote à écrire (Lesson, Homework ou SchoolEvent).
:param result: Résultat à mettre à jour (compteurs et erreurs). :param result: Résultat à mettre à jour (compteurs et erreurs).
:param is_update: Si ``True``, l'opération est une mise à jour, :param is_update: Si ``True``, l'opération est une mise à jour,
sinon un ajout. 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" action = "mise à jour" if is_update else "ajout"
uid = _model_uid(model) uid = _model_uid(model)
@@ -151,7 +183,15 @@ class CalDAVSyncExecutor:
return return
try: try:
vcalendar_text = model_to_vcalendar_text(model) 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: except PronoteSyncError as exc:
result.errors.append(redact_secrets(str(exc))) result.errors.append(redact_secrets(str(exc)))
return return

View File

@@ -28,20 +28,32 @@ from pronote_sync.sync.serialization import (
def compute_plan( def compute_plan(
pronote_data: PronoteData, pronote_data: PronoteData,
remote_managed: list[tuple[str, Any]], remote_managed: list[tuple[str, str, Any]],
) -> CalDAVSyncPlan: ) -> tuple[CalDAVSyncPlan, dict[str, str]]:
"""Calcule le plan de synchronisation CalDAV. """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 pronote_data: Données Pronote normalisées (cours, devoirs, événements).
:param remote_managed: Liste de couples (uid, vevent) pour les événements :param remote_managed: Liste de tuples (raw_uid, canonical_uid, vevent)
distants marqués comme gérés par pronote-sync. 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 :return: Tuple (plan de synchronisation, mapping canonical_uid -> raw_uid).
suppressions pour chaque type d'événement. Les listes ``*_to_remove`` contiennent l'UID brut distant, les autres
:rtype: CalDAVSyncPlan listes contiennent les modèles Pronote locaux.
:rtype: tuple[CalDAVSyncPlan, dict[str, str]]
""" """
remote_signatures: dict[str, str] = {} #: canonical_uid -> signature sémantique du VEVENT distant (pour l'appariement).
for uid, vevent in remote_managed: remote_signatures_by_canonical: dict[str, str] = {}
remote_signatures[uid] = component_to_signature(vevent) #: 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_add: list[Lesson] = []
lessons_to_update: 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} local_lessons_by_uid: dict[str, Lesson] = {lesson.id: lesson for lesson in pronote_data.lessons}
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)) 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) 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) 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_add: list[Homework] = []
homeworks_to_update: 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 f"homework-{homework.id}": homework for homework in pronote_data.homeworks
} }
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)) 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) 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) 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_add: list[SchoolEvent] = []
school_events_to_update: list[SchoolEvent] = [] school_events_to_update: list[SchoolEvent] = []
@@ -89,24 +92,39 @@ def compute_plan(
for event in pronote_data.school_events for event in pronote_data.school_events
} }
for school_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)) 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) 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) 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( # Détection des événements distants orphelins : un UID canonique distant
lessons_to_add=lessons_to_add, # absent des données locales est supprimé en ciblant l'UID brut stocké sur
lessons_to_update=lessons_to_update, # le serveur. L'acheminement vers la bonne liste de suppression se fait sur
lessons_to_remove=lessons_to_remove, # le préfixe de l'UID canonique.
homeworks_to_add=homeworks_to_add, for canonical_uid in remote_signatures_by_canonical:
homeworks_to_update=homeworks_to_update, raw_uid = remote_raw_by_canonical[canonical_uid]
homeworks_to_remove=homeworks_to_remove, if canonical_uid.startswith("homework-"):
school_events_to_add=school_events_to_add, if canonical_uid not in local_homeworks_by_uid:
school_events_to_update=school_events_to_update, homeworks_to_remove.append(raw_uid)
school_events_to_remove=school_events_to_remove, 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.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.homework import Homework 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``. #: Propriété iCalendar marquant un événement géré par ``pronote-sync``.
MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED" MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED"
@@ -175,7 +176,10 @@ def component_to_signature(component: Component) -> str:
continue continue
value = getattr(raw, "dt", raw) value = getattr(raw, "dt", raw)
if hasattr(value, "isoformat"): if hasattr(value, "isoformat"):
rendered = value.isoformat() if isinstance(value, datetime):
rendered = normalize_datetime_to_utc(value).isoformat()
else:
rendered = value.isoformat()
else: else:
rendered = str(value).strip().lower() rendered = str(value).strip().lower()
props.append(f"{key.lower()}={rendered}") props.append(f"{key.lower()}={rendered}")

View File

@@ -13,7 +13,7 @@ from __future__ import annotations
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timedelta from datetime import datetime, time, timedelta
from typing import Any from typing import Any
from pronote_sync.config.settings import Settings from pronote_sync.config.settings import Settings
@@ -31,28 +31,69 @@ def synchronize(
pronote_data: PronoteData, pronote_data: PronoteData,
settings: Settings, settings: Settings,
client_factory: Callable[..., Any] | None = None, client_factory: Callable[..., Any] | None = None,
*,
now: datetime | None = None,
) -> CalDAVSyncResult: ) -> CalDAVSyncResult:
"""Synchronise les données Pronote vers le calendrier CalDAV. """Synchronise les données Pronote vers le calendrier CalDAV.
Enchaîne les trois phases : connexion à la passerelle, scan distant et Enchaîne les trois phases : connexion à la passerelle, scan distant et
calcul du plan, puis exécution (ou simulation dry-run). 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 pronote_data: Données Pronote normalisées à synchroniser.
:param settings: Configuration racine du pipeline. :param settings: Configuration racine du pipeline.
:param client_factory: Fabrique optionnelle de client DAV (pour les tests). :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). :return: Résultat de la synchronisation (statut, compteurs, erreurs).
:rtype: CalDAVSyncResult :rtype: CalDAVSyncResult
:raises PronoteSyncError: Si la configuration CalDAV est incomplète ou si la :raises PronoteSyncError: Si la configuration CalDAV est incomplète ou si la
connexion échoue. connexion échoue.
""" """
# Fenêtre temporelle fondée sur l'instant courant. Aucune abstraction now = now or datetime.now()
# d'horloge n'existe encore dans le dépôt (les données Pronote sont par window_start = datetime.combine(
# convention naïves en heure locale) : ``datetime.now()`` est utilisé ici, (now - timedelta(days=settings.app.sync_past_days)).date(),
# point d'entrée de l'orchestration, et pourrait être refactoré plus tard time(0, 0),
# vers une horloge injectable sans changer le contrat. )
now = datetime.now() window_end = datetime.combine(
start = now - timedelta(days=settings.app.sync_past_days) (now + timedelta(days=settings.app.sync_future_days + 1)).date(),
end = now + timedelta(days=settings.app.sync_future_days) 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 ( if (
settings.caldav.url is None settings.caldav.url is None
@@ -65,13 +106,13 @@ def synchronize(
gateway = CalDAVGateway(settings.caldav, client_factory=client_factory) gateway = CalDAVGateway(settings.caldav, client_factory=client_factory)
try: try:
with gateway: 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( logger.info(
"Synchronisation CalDAV : %d événements distants gérés trouvés", "Synchronisation CalDAV : %d événements distants gérés trouvés",
len(remote_managed), len(remote_managed),
) )
plan = compute_plan(pronote_data, remote_managed) plan, remote_raw_by_canonical = compute_plan(filtered_pronote, remote_managed)
n_add = ( n_add = (
len(plan.lessons_to_add) len(plan.lessons_to_add)
+ len(plan.homeworks_to_add) + len(plan.homeworks_to_add)
@@ -97,7 +138,7 @@ def synchronize(
if settings.app.dry_run: if settings.app.dry_run:
logger.info("DRY-RUN : aucune écriture ne sera effectuée sur le calendrier") logger.info("DRY-RUN : aucune écriture ne sera effectuée sur le calendrier")
executor = CalDAVSyncExecutor(gateway, dry_run=settings.app.dry_run) 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: except PronoteSyncError:
logger.error("Échec de la synchronisation CalDAV") logger.error("Échec de la synchronisation CalDAV")
# Re-lève la même exception de domaine sans en créer de nouvelle. # 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 hashlib
import re import re
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo
_TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$") _TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$")
_EDUCATION_SUFFIX_PATTERN = re.compile(r"-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) 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( def generate_deterministic_uid(
start: datetime, start: datetime,
end: datetime, end: datetime,
@@ -56,8 +72,8 @@ def generate_deterministic_uid(
:rtype: str :rtype: str
""" """
parts = [ parts = [
start.isoformat(), normalize_datetime_to_utc(start).isoformat(),
end.isoformat(), normalize_datetime_to_utc(end).isoformat(),
subject, subject,
",".join(sorted(teachers)), ",".join(sorted(teachers)),
",".join(sorted(rooms)), ",".join(sorted(rooms)),

View File

@@ -85,13 +85,50 @@ class FakeCalendar:
self._server = server self._server = server
self.url = "https://caldav.example.com/remote.php/dav/calendars/test-user/pronote-sync/" self.url = "https://caldav.example.com/remote.php/dav/calendars/test-user/pronote-sync/"
def date_search( def search(
self, start: datetime, end: datetime, expand: bool = True self,
*,
start: datetime | None = None,
end: datetime | None = None,
event: bool = True,
expand: bool = True,
**kwargs: Any,
) -> list[FakeCalendarEvent]: ) -> 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] = [] results: list[FakeCalendarEvent] = []
for uid, ical_text in self._server._events.items(): 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 return results
def get_event_by_uid(self, uid: str) -> FakeCalendarEvent: def get_event_by_uid(self, uid: str) -> FakeCalendarEvent:
@@ -266,6 +303,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
# Vérifications # Vérifications
@@ -308,6 +346,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
assert result1.added == 1 assert result1.added == 1
@@ -316,6 +355,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
assert result2.status == CalDAVSyncStatus.SKIPPED assert result2.status == CalDAVSyncStatus.SKIPPED
assert result2.added == 0 assert result2.added == 0
@@ -366,6 +406,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, 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 # Vérifications : le résultat indique un ajout, mais le serveur reste vide
@@ -415,6 +456,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
# Vérifications # Vérifications
@@ -454,6 +496,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
# Vérifications # Vérifications
@@ -496,6 +539,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
# Vérifications # Vérifications
@@ -538,6 +582,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, 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 # Vérifications : l'événement non géré est toujours présent
@@ -593,6 +638,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
# Vérifications # Vérifications
@@ -632,6 +678,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
# Vérifications # Vérifications
@@ -667,6 +714,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 12, 20, 12, 0),
) )
# Vérifications # Vérifications
@@ -758,6 +806,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data, pronote_data=pronote_data,
settings=full_settings, settings=full_settings,
client_factory=fake_caldav_server.client_factory, client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
) )
# Vérifications # Vérifications
@@ -770,3 +819,111 @@ class TestCalDAVSynchronize:
events = fake_caldav_server.get_events() events = fake_caldav_server.get_events()
assert "UNMANAGED-001" in events assert "UNMANAGED-001" in events
assert len(events) == 4 # L-001 (mis à jour), L-002, hw-002, UNMANAGED-001 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 :return: None
""" """
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False) 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() mock_gateway.upsert_event.assert_called_once()
assert result.updated == 1 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( def test_real_delete_calls_gateway(
mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan
) -> None: ) -> None:
@@ -380,7 +437,10 @@ def test_real_execution_all_operations(
) )
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False) 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 # upsert_event appelé pour les ajouts et mises à jour
# 1 lesson add + 1 lesson update + 1 homework add + 1 school event add = 4 # 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 from __future__ import annotations
import logging
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock 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 from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
if TYPE_CHECKING: if TYPE_CHECKING:
pass from _pytest.logging import LogCaptureFixture
# --- Fixtures --- # --- 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[ mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0 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) start = datetime(2026, 1, 1)
end = datetime(2026, 12, 31) 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 # Seuls les événements gérés doivent être retournés
assert len(result) == 1 assert len(result) == 1
uid, vevent = result[0] raw_uid, canonical_uid, vevent = result[0]
assert uid == "test-uid-123" 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" 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[ mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0 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: with pytest.raises(PronoteSyncError) as exc_info:
gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31)) 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( def test_upsert_event_updates_existing_event_by_uid(
caldav_settings: CalDAVSettings, caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock, mock_client_factory: MagicMock,
mock_managed_event: MagicMock,
) -> None: ) -> 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 caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients mockée. :param mock_client_factory: Usine de clients mockée.
:param mock_managed_event: Événement géré mocké.
:return: None :return: None
""" """
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) 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[ mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0 0
] ]
mock_event = MagicMock() mock_calendar.get_event_by_uid.return_value = mock_managed_event
mock_calendar.get_event_by_uid.return_value = mock_event
vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR" vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR"
gateway.upsert_event(vcalendar_text, "test-123") gateway.upsert_event(vcalendar_text, "test-123")
mock_calendar.get_event_by_uid.assert_called_once_with("test-123") mock_calendar.get_event_by_uid.assert_called_once_with("test-123")
assert mock_event.data == vcalendar_text assert mock_managed_event.data == vcalendar_text
mock_event.save.assert_called_once() mock_managed_event.save.assert_called_once()
mock_calendar.add_event.assert_not_called() mock_calendar.add_event.assert_not_called()
@@ -474,30 +477,66 @@ def test_upsert_event_caldav_error_raises(
# --- delete_event tests --- # --- delete_event tests ---
def test_delete_event_calls_calendar( def test_upsert_event_refuses_unmanaged_event(
caldav_settings: CalDAVSettings, caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock, mock_client_factory: MagicMock,
mock_unmanaged_event: MagicMock,
) -> None: ) -> 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 caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients mockée. :param mock_client_factory: Usine de clients mockée.
:param mock_unmanaged_event: Événement non géré mocké.
:return: None :return: None
""" """
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
gateway.connect() gateway.connect()
# Configurer le calendrier pour retourner un événement mocké
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0 0
] ]
mock_event = MagicMock() mock_calendar.get_event_by_uid.return_value = mock_unmanaged_event
mock_calendar.get_event_by_uid.return_value = mock_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") gateway.delete_event("test-uid-123")
mock_calendar.get_event_by_uid.assert_called_once_with("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( 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 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 --- # --- 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") lesson = _make_lesson(lesson_id="L-1234")
pronote_data = _make_pronote_data(lessons=[lesson]) 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 len(plan.lessons_to_add) == 1
assert plan.lessons_to_add[0].id == "L-1234" 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), dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 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_add) == 0
assert len(plan.lessons_to_update) == 1 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 # Créer un VEVENT distant avec les mêmes propriétés
remote_event = lesson_to_vevent(lesson) 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_add) == 0
assert len(plan.lessons_to_update) == 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), dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 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_add) == 0
assert len(plan.lessons_to_update) == 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" 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: def test_lesson_cancelled_preserved() -> None:
"""Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour). """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) lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED)
pronote_data = _make_pronote_data(lessons=[lesson]) 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 # Un cours annulé doit aller dans lessons_to_add comme n'importe quel autre cours
assert len(plan.lessons_to_add) == 1 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") homework = _make_homework(homework_id="HW-5678")
pronote_data = _make_pronote_data(homeworks=[homework]) 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 len(plan.homeworks_to_add) == 1
assert plan.homeworks_to_add[0].id == "HW-5678" 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), dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 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_add) == 0
assert len(plan.homeworks_to_update) == 1 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), dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 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_add) == 0
assert len(plan.homeworks_to_update) == 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), from_date=date(2026, 12, 20),
) )
pronote_data = _make_pronote_data(school_events=[school_event]) 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 len(plan.school_events_to_add) == 1
assert plan.school_events_to_add[0].label == "Vacances de Noël" 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), dtstart=datetime(2026, 12, 20, 0, 0),
dtend=datetime(2027, 1, 5, 0, 0), dtend=datetime(2027, 1, 5, 0, 0),
) )
remote_managed: list[tuple[str, Event]] = [ remote_managed: list[tuple[str, str, Event]] = [
("school-event-Vacances de Noël-2026-12-20", remote_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_add) == 0
assert len(plan.school_events_to_update) == 1 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), dtstart=datetime(2026, 1, 1, 0, 0),
dtend=datetime(2026, 1, 2, 0, 0), dtend=datetime(2026, 1, 2, 0, 0),
) )
remote_managed: list[tuple[str, Event]] = [ remote_managed: list[tuple[str, str, Event]] = [
("school-event-Ancien événement-2026-01-01", remote_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_add) == 0
assert len(plan.school_events_to_update) == 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), dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 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 # Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0 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), dtstart=datetime(2026, 12, 20, 0, 0),
dtend=datetime(2027, 1, 5, 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 # Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0 assert len(plan.lessons_to_remove) == 0
@@ -485,9 +557,9 @@ def test_empty_inputs_empty_plan() -> None:
:return: None :return: None
""" """
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[]) 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_add) == 0
assert len(plan.lessons_to_update) == 0 assert len(plan.lessons_to_update) == 0
@@ -535,12 +607,12 @@ def test_mixed_scenario() -> None:
dtend=datetime(2026, 1, 15, 9, 0), dtend=datetime(2026, 1, 15, 9, 0),
) )
remote_managed: list[tuple[str, Event]] = [ remote_managed: list[tuple[str, str, Event]] = [
("L-0002", remote_lesson2), ("L-0002", "L-0002", remote_lesson2),
("L-0003", remote_lesson3), ("L-0003", "L-0003", remote_lesson3),
] ]
plan = compute_plan(pronote_data, remote_managed) plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Ajouts # Ajouts
assert len(plan.lessons_to_add) == 1 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) # 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 # 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 # Le cours doit être dans lessons_to_add
assert len(plan.lessons_to_add) == 1 assert len(plan.lessons_to_add) == 1

View File

@@ -257,7 +257,7 @@ class TestCalDAVGatewaySecurity:
mock_principal = MagicMock() mock_principal = MagicMock()
mock_calendar = MagicMock() mock_calendar = MagicMock()
mock_calendar.url = "https://caldav.example.com/cal/" 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}" f"Search failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}"
) )
mock_principal.calendars.return_value = [mock_calendar] mock_principal.calendars.return_value = [mock_calendar]