Compare commits
4 Commits
prep/scaff
...
fix/m1-sca
| Author | SHA1 | Date | |
|---|---|---|---|
|
292aef4f5b
|
|||
|
2f6184df0b
|
|||
|
a0ac75cec6
|
|||
|
aaca78c55d
|
@@ -13,6 +13,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
|
|||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
CALDAV_USERNAME=user@example.com
|
CALDAV_USERNAME=user@example.com
|
||||||
CALDAV_PASSWORD=your_caldav_password
|
CALDAV_PASSWORD=your_caldav_password
|
||||||
|
CALDAV_CALENDAR_PATH=/pronote-sync/
|
||||||
|
|
||||||
# Fenêtre de synchronisation (jours)
|
# Fenêtre de synchronisation (jours)
|
||||||
SYNC_PAST_DAYS=7
|
SYNC_PAST_DAYS=7
|
||||||
@@ -34,9 +35,10 @@ XMPP_TIMEOUT=30
|
|||||||
|
|
||||||
# --- IA (optionnelle) ---
|
# --- IA (optionnelle) ---
|
||||||
AI_ENABLED=true
|
AI_ENABLED=true
|
||||||
|
AI_PROVIDER=openai
|
||||||
AI_BASE_URL=https://api.openai.com/v1
|
AI_BASE_URL=https://api.openai.com/v1
|
||||||
AI_API_KEY=your_ai_api_key
|
AI_API_KEY=your_ai_api_key
|
||||||
AI_MODEL=gpt-4o-mini
|
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
||||||
|
|
||||||
# --- Blog ---
|
# --- Blog ---
|
||||||
BLOG_ENABLED=false
|
BLOG_ENABLED=false
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -51,3 +51,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# --- Logs ---
|
# --- Logs ---
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# --- Artefacts iCal temporaires ---
|
||||||
|
# Ignorer les exports iCal temporaires (peuvent contenir des jetons ou données scolaires).
|
||||||
|
# Les fixtures iCal anonymisées (M13) restent suivies car elles ne correspondent pas au suffixe *.tmp.ics.
|
||||||
|
*.tmp.ics
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ repos:
|
|||||||
name: mypy
|
name: mypy
|
||||||
entry: mypy
|
entry: mypy
|
||||||
language: python
|
language: python
|
||||||
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"]
|
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0"]
|
||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,10 @@
|
|||||||
{
|
{
|
||||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||||
|
"filename": ".secrets.baseline"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||||
"min_level": 2
|
"min_level": 2
|
||||||
@@ -135,10 +139,11 @@
|
|||||||
"type": "Hex High Entropy String",
|
"type": "Hex High Entropy String",
|
||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": false,
|
"is_secret": false,
|
||||||
"line_number": 5073
|
"is_verified": true,
|
||||||
|
"line_number": 5117
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-05T17:53:24Z"
|
"generated_at": "2026-09-05T21:51:55Z"
|
||||||
}
|
}
|
||||||
|
|||||||
44
AGENTS.md
44
AGENTS.md
@@ -189,22 +189,38 @@ Cette section s'applique uniquement lorsque le travail est exécuté avec le sys
|
|||||||
|
|
||||||
Les rôles d'agents disponibles pour ce projet sont les suivants :
|
Les rôles d'agents disponibles pour ce projet sont les suivants :
|
||||||
|
|
||||||
- `@architect` : Arbitrages d'architecture et choix techniques structurants pour le pipeline `pronote-sync`.
|
- `@architect` : Arbitrages d'architecture et choix techniques structurants. **Ne produit pas de code.**
|
||||||
- `@coder` : Opérations de développement et changements de code dans le projet.
|
- `@coder` : Écrit et modifie du code, de la configuration et des scripts. **Ne valide pas** (ruff, mypy, pytest) — c'est le rôle de `@verifier`. **Ne diagnostique pas** — c'est le rôle de `@debugger`.
|
||||||
- `@debugger` : Reproduction d'un symptôme et établissement de sa cause profonde (ex. : échec de synchronisation, repli iCal/pronotepy).
|
- `@debugger` : Reproduit un symptôme et établit sa cause profonde. **Ne modifie pas le code.**
|
||||||
- `@explorer` : Exploration du dépôt en lecture seule et fourniture de contexte factuel.
|
- `@explorer` : Explore le dépôt en lecture seule. **Ne modifie rien, n'exécute pas de commandes.**
|
||||||
- `@orchestrator` : Compréhension globale du projet, définition des jalons, coordination et garantie du résultat.
|
- `@orchestrator` : Compréhension globale, définition des jalons, coordination et garantie du résultat. **N'écrit pas de code.**
|
||||||
- `@planner` : Transformation d'une demande complexe en unités exécutables avec frontières et dépendances claires.
|
- `@planner` : Transforme une demande complexe en unités exécutables. **Ne dirige aucun technicien.**
|
||||||
- `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité.
|
- `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité. **Ne modifie pas le code.**
|
||||||
- `@security-auditor` : Audit indépendant d'une surface de sécurité désignée (ex. : gestion des secrets, masquage des données).
|
- `@security-auditor` : Audit indépendant d'une surface de sécurité. **Ne modifie pas le code.**
|
||||||
- `@tech-writer` : Rédaction et maintenance de documentation technique exacte et vérifiable.
|
- `@tech-writer` : Rédige et maintient la documentation. **N'écrit pas de code applicatif.**
|
||||||
- `@test-engineer` : Conception, écriture et exécution de tests ciblés (unitaires, intégration, mocks).
|
- `@test-engineer` : Conçoit, écrit et exécute des tests ciblés. **N'écrit pas de code de production.**
|
||||||
- `@ui-designer` : conception et implémentation d'interfaces Web et terminal.
|
- `@ui-designer` : Conçoit et implémente les interfaces Web et terminal.
|
||||||
- `@verifier` : Vérification indépendante du comportement livré, des régressions et du respect des conventions (idempotence, mode dégradé).
|
- `@verifier` : Vérifie indépendamment le comportement livré, les régressions et le respect des conventions (ruff, mypy, pytest, bandit, idempotence, mode dégradé). **Ne modifie pas le code.**
|
||||||
- `@web-explorer` : Recherche et extraction de sources Web vérifiables (ex. : documentation Pronote, CalDAV, XMPP).
|
- `@web-explorer` : Recherche et extrait des sources Web vérifiables. **Ne modifie pas le dépôt.**
|
||||||
|
|
||||||
> **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`).
|
> **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`).
|
||||||
|
|
||||||
|
### Séparation des rôles
|
||||||
|
|
||||||
|
| Type de tâche | Agent responsable | Ne pas confier à |
|
||||||
|
|---|---|---|
|
||||||
|
| Écrire/modifier du code | `@coder` | `@verifier`, `@explorer` |
|
||||||
|
| Valider (ruff, mypy, pytest, bandit) | `@verifier` | `@coder` |
|
||||||
|
| Diagnostiquer un bug | `@debugger` | `@coder` |
|
||||||
|
| Écrire un test | `@test-engineer` | `@coder` |
|
||||||
|
| Rédiger de la documentation | `@tech-writer` | `@coder` |
|
||||||
|
| Explorer le dépôt (lecture) | `@explorer` | `@coder`, `@verifier` |
|
||||||
|
| Arbitrage technique structurant | `@architect` | `@coder`, `@planner` |
|
||||||
|
| Revue de code | `@reviewer` | `@coder`, `@verifier` |
|
||||||
|
| Audit de sécurité | `@security-auditor` | `@coder`, `@verifier` |
|
||||||
|
| Recherche web | `@web-explorer` | `@explorer` |
|
||||||
|
| Découpage de travail complexe | `@planner` | `@coder` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Workflow de modification
|
## 10. Workflow de modification
|
||||||
@@ -213,7 +229,7 @@ Les rôles d'agents disponibles pour ce projet sont les suivants :
|
|||||||
2. Préserver les changements existants de l'utilisateur.
|
2. Préserver les changements existants de l'utilisateur.
|
||||||
3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable.
|
3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable.
|
||||||
4. Faire une modification étroite et cohérente, en respectant les conventions du projet (idempotence, mode dégradé, repli iCal/pronotepy).
|
4. Faire une modification étroite et cohérente, en respectant les conventions du projet (idempotence, mode dégradé, repli iCal/pronotepy).
|
||||||
5. Vérifier le comportement nominal et les cas d'erreur, notamment :
|
5. Faire vérifier le comportement par `@verifier` (ruff, mypy, pytest, bandit) et les cas d'erreur, notamment :
|
||||||
- Succès de la synchronisation Pronote → CalDAV/XMPP.
|
- Succès de la synchronisation Pronote → CalDAV/XMPP.
|
||||||
- Repli vers iCal en cas d'échec de `pronotepy`.
|
- Repli vers iCal en cas d'échec de `pronotepy`.
|
||||||
- Gestion des erreurs explicites.
|
- Gestion des erreurs explicites.
|
||||||
|
|||||||
@@ -267,10 +267,16 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
|
|||||||
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
|
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
|
||||||
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
|
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
|
||||||
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
|
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
|
||||||
|
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
|
||||||
|
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
|
||||||
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
|
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
|
||||||
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
|
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
|
||||||
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
|
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
|
||||||
|
|
||||||
|
> ⚠️ **Décision d'implémentation** :
|
||||||
|
> `XMPP_RECIPIENT` a été renommé en `XMPP_TO` dans l'implémentation (aligné avec §10.2.1).
|
||||||
|
> Des variables XMPP supplémentaires ont été ajoutées : `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_RESOURCE`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`.
|
||||||
|
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
|
||||||
|
|
||||||
#### 3.1.2 Variables optionnelles
|
#### 3.1.2 Variables optionnelles
|
||||||
|
|
||||||
@@ -281,11 +287,16 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
|
|||||||
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
|
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
|
||||||
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
|
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
|
||||||
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
|
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
|
||||||
|
|
||||||
|
> ⚠️ **Décision d'implémentation** :
|
||||||
|
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
|
||||||
|
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
|
||||||
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
|
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
|
||||||
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
||||||
|
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
|
||||||
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
|
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
|
||||||
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
|
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
|
||||||
| `AI_MODEL` | Modèle IA à utiliser. | `gpt-4o-mini` | `str` |
|
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
|
||||||
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
|
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
|
||||||
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
||||||
|
|
||||||
@@ -308,6 +319,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
|
|||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
CALDAV_USERNAME=user@example.com
|
CALDAV_USERNAME=user@example.com
|
||||||
CALDAV_PASSWORD=your_caldav_password
|
CALDAV_PASSWORD=your_caldav_password
|
||||||
|
CALDAV_CALENDAR_PATH=/pronote-sync/
|
||||||
|
|
||||||
# Fenêtre de synchronisation (jours)
|
# Fenêtre de synchronisation (jours)
|
||||||
SYNC_PAST_DAYS=7
|
SYNC_PAST_DAYS=7
|
||||||
@@ -323,9 +335,10 @@ XMPP_RECIPIENT=parent@example.com
|
|||||||
|
|
||||||
# --- IA (optionnelle) ---
|
# --- IA (optionnelle) ---
|
||||||
AI_ENABLED=true
|
AI_ENABLED=true
|
||||||
|
AI_PROVIDER=openai
|
||||||
AI_BASE_URL=https://api.openai.com/v1
|
AI_BASE_URL=https://api.openai.com/v1
|
||||||
AI_API_KEY=your_ai_api_key
|
AI_API_KEY=your_ai_api_key
|
||||||
AI_MODEL=gpt-4o-mini
|
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
||||||
|
|
||||||
# --- Divers ---
|
# --- Divers ---
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
@@ -335,6 +348,15 @@ LOG_LEVEL=INFO
|
|||||||
|
|
||||||
### 3.2 Modèle Pydantic pour la configuration
|
### 3.2 Modèle Pydantic pour la configuration
|
||||||
|
|
||||||
|
> ⚠️ **Décision d'implémentation** :
|
||||||
|
> L'implémentation utilise le style moderne de Pydantic v2 : `model_config = ConfigDict(frozen=True)` au lieu de `class Config`, pas de `json_encoders` (la sérialisation ISO est native en v2), `str | None` au lieu de `Optional[str]`, `list[str]` au lieu de `List[str]`.
|
||||||
|
> `AISettings.enabled` a pour valeur par défaut `False` (et non `True` comme indiqué dans le bloc de code).
|
||||||
|
> `XmppSettings` est entièrement défini en §10.2.3 avec tous les champs optionnels (valeurs par défaut) pour que `Settings()` fonctionne sans `.env`.
|
||||||
|
> `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et `rss_url` par défaut.
|
||||||
|
> `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`.
|
||||||
|
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"` (et non `"/pronote-digest/"`).
|
||||||
|
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"` (et non `"pronote-digest"`).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
from pydantic import SecretStr, Field
|
from pydantic import SecretStr, Field
|
||||||
@@ -452,6 +474,11 @@ Toutes les exceptions externes (HTTP, Pronote, CalDAV, XMPP, IA) **doivent** êt
|
|||||||
|
|
||||||
#### 4.2.1 Masquage des URLs (`redaction.py`)
|
#### 4.2.1 Masquage des URLs (`redaction.py`)
|
||||||
|
|
||||||
|
> ⚠️ **Décision d'implémentation** :
|
||||||
|
> `redact_exception` est implémenté comme une **fonction au niveau du module** dans `utils/redaction.py`, et non comme une méthode de `RedactingFormatter` (contrairement à §4.2.2 où elle apparaît comme une méthode).
|
||||||
|
> `redact_url` utilise `urlsplit`/`urlunsplit`/`parse_qsl` au lieu de `urlparse`/`urlunparse`/`parse_qs`.
|
||||||
|
> La correspondance des clés sensibles est insensible à la casse.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||||
@@ -506,6 +533,11 @@ def redact_secrets(text: str) -> str:
|
|||||||
|
|
||||||
#### 4.2.2 Configuration des logs (`logging.py`)
|
#### 4.2.2 Configuration des logs (`logging.py`)
|
||||||
|
|
||||||
|
> ⚠️ **Décision d'implémentation** :
|
||||||
|
> `redact_exception` est une fonction au niveau du module dans `redaction.py`, et non une méthode de `RedactingFormatter`.
|
||||||
|
> `setup_logging` utilise `logging.getLevelNamesMapping()` (Python 3.11+) au lieu de `getattr(logging, ...)`.
|
||||||
|
> `RedactingFormatter.format` gère à la fois les `record.args` de type tuple et dict.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -1115,7 +1147,7 @@ Ajouter les variables suivantes dans la configuration :
|
|||||||
|
|
||||||
| **Variable** | **Description** | **Valeur par défaut** | **Type** |
|
| **Variable** | **Description** | **Valeur par défaut** | **Type** |
|
||||||
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
|
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
|
||||||
| `BLOG_RSS_ENABLED` | Activer la récupération du blog. | `False` | `bool` |
|
| `BLOG_ENABLED` | Activer la récupération du blog. | `False` | `bool` |
|
||||||
| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` |
|
| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` |
|
||||||
|
|
||||||
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
|
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
|
||||||
@@ -1150,7 +1182,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
# --- Blog du collège ---
|
# --- Blog du collège ---
|
||||||
BLOG_RSS_ENABLED=true
|
BLOG_ENABLED=true
|
||||||
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1684,6 +1716,10 @@ homeworks = collect_homeworks(lessons, target_date)
|
|||||||
|
|
||||||
#### 5.1.5 Normalisation des UID
|
#### 5.1.5 Normalisation des UID
|
||||||
|
|
||||||
|
> ⚠️ **Décision d'implémentation** :
|
||||||
|
> La fonction est nommée `normalize_pronote_uid` (et non `normalize_uid` comme référencé dans TODO.md).
|
||||||
|
> `generate_deterministic_uid` utilise `hashlib.sha1(payload, usedforsecurity=False)` pour satisfaire la règle bandit B324 (le hachage n'est pas utilisé pour la sécurité).
|
||||||
|
|
||||||
Les UID Pronote contiennent des **suffixes temporels** qui changent à chaque export. Exemple réel :
|
Les UID Pronote contiennent des **suffixes temporels** qui changent à chaque export. Exemple réel :
|
||||||
```
|
```
|
||||||
UID:Cours-16027-1-20260904T120218Z-Index-Education
|
UID:Cours-16027-1-20260904T120218Z-Index-Education
|
||||||
@@ -2360,6 +2396,14 @@ class PronoteFetcher:
|
|||||||
|
|
||||||
## 6. Modèle de données Pydantic
|
## 6. Modèle de données Pydantic
|
||||||
|
|
||||||
|
> ⚠️ **Décision d'implémentation (M3)** :
|
||||||
|
> Tous les modèles utilisent `model_config = ConfigDict(frozen=True)` (Pydantic v2), et non `class Config: frozen = True`.
|
||||||
|
> Pas de `json_encoders` : la sérialisation ISO pour `datetime`/`date` est native dans Pydantic v2.
|
||||||
|
> Les énumérations utilisent `enum.StrEnum` (Python 3.11+) au lieu de `(str, Enum)` (règle ruff UP042).
|
||||||
|
> Dans `HomeworkBlock`, le champ `date` utilise un alias d'import `_date` (`from datetime import date as _date`) pour éviter un conflit de nom/champ dans Pydantic v2. Même chose pour `time` → `_time` dans `TheoreticalLesson`.
|
||||||
|
> `XmppMessage.external_info` est de type `ExternalInfo | None` avec une valeur par défaut `None` (et non `default_factory=ExternalInfo`) : le pipeline passe `None` lorsqu'il n'y a pas d'informations externes.
|
||||||
|
> Les modèles sont répartis en 10 modules domaines (agenda, homework, message, blog, diff, pronote, sync, synthesis, xmpp) avec `__init__.py` réexportant les 22 noms via `__all__`.
|
||||||
|
|
||||||
### 6.1 Principes
|
### 6.1 Principes
|
||||||
- **Modèles distincts par domaine** : Ne pas créer un unique modèle fourre-tout. Chaque étape du pipeline utilise des modèles dédiés (décision architecturale).
|
- **Modèles distincts par domaine** : Ne pas créer un unique modèle fourre-tout. Chaque étape du pipeline utilise des modèles dédiés (décision architecturale).
|
||||||
- **Validation stricte** : Utiliser Pydantic pour valider les données dès leur création.
|
- **Validation stricte** : Utiliser Pydantic pour valider les données dès leur création.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import SecretStr
|
from pydantic import Field, SecretStr, field_serializer
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ class PronoteSettings(BaseSettings):
|
|||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
|
||||||
|
|
||||||
ical_url: str | None = None
|
ical_url: SecretStr | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: SecretStr | None = None
|
password: SecretStr | None = None
|
||||||
ent: str | None = None
|
ent: str | None = None
|
||||||
@@ -31,6 +31,18 @@ class PronoteSettings(BaseSettings):
|
|||||||
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
messages_source: Literal["pronotepy"] = "pronotepy"
|
messages_source: Literal["pronotepy"] = "pronotepy"
|
||||||
|
|
||||||
|
@field_serializer("ical_url")
|
||||||
|
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
||||||
|
"""Masque l'URL iCal lors de la sérialisation (repr, str, JSON).
|
||||||
|
|
||||||
|
:param value: Valeur du champ ``ical_url``.
|
||||||
|
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
|
||||||
|
:rtype: str | None
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return "**********"
|
||||||
|
|
||||||
|
|
||||||
class CalDAVSettings(BaseSettings):
|
class CalDAVSettings(BaseSettings):
|
||||||
"""Paramètres d'accès au serveur CalDAV de destination.
|
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||||
@@ -121,12 +133,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
pronote: PronoteSettings = PronoteSettings()
|
pronote: PronoteSettings = Field(default_factory=PronoteSettings)
|
||||||
caldav: CalDAVSettings = CalDAVSettings()
|
caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
|
||||||
xmpp: XmppSettings = XmppSettings()
|
xmpp: XmppSettings = Field(default_factory=XmppSettings)
|
||||||
ai: AISettings = AISettings()
|
ai: AISettings = Field(default_factory=AISettings)
|
||||||
blog: BlogSettings = BlogSettings()
|
blog: BlogSettings = Field(default_factory=BlogSettings)
|
||||||
app: AppSettings = AppSettings()
|
app: AppSettings = Field(default_factory=AppSettings)
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import time as _time
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class Status(StrEnum):
|
class Status(StrEnum):
|
||||||
@@ -30,6 +30,8 @@ class LessonStatus(StrEnum):
|
|||||||
class HomeworkBlock(BaseModel):
|
class HomeworkBlock(BaseModel):
|
||||||
"""Représente un bloc de devoir extrait de la description d'un cours."""
|
"""Représente un bloc de devoir extrait de la description d'un cours."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
kind: Literal["due", "assigned"] = Field(
|
kind: Literal["due", "assigned"] = Field(
|
||||||
..., description="Type de bloc (échéance ou attribution)"
|
..., description="Type de bloc (échéance ou attribution)"
|
||||||
)
|
)
|
||||||
@@ -47,16 +49,28 @@ class Lesson(BaseModel):
|
|||||||
start: datetime = Field(..., description="Date/heure de début")
|
start: datetime = Field(..., description="Date/heure de début")
|
||||||
end: datetime = Field(..., description="Date/heure de fin")
|
end: datetime = Field(..., description="Date/heure de fin")
|
||||||
subject: str = Field(..., description="Matière")
|
subject: str = Field(..., description="Matière")
|
||||||
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
|
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
||||||
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
|
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
|
||||||
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
|
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
|
||||||
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
|
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
|
||||||
content: str | None = Field(None, description="Contenu pédagogique")
|
content: str | None = Field(None, description="Contenu pédagogique")
|
||||||
homework_blocks: list[HomeworkBlock] = Field(
|
homework_blocks: tuple[HomeworkBlock, ...] = Field(
|
||||||
default_factory=list,
|
default=(),
|
||||||
description="Blocs de devoirs extraits de la description",
|
description="Blocs de devoirs extraits de la description",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_time_order(self) -> Lesson:
|
||||||
|
"""Valide que l'heure de fin est postérieure à l'heure de début.
|
||||||
|
|
||||||
|
:return: L'instance validée.
|
||||||
|
:rtype: Lesson
|
||||||
|
:raises ValueError: Si ``end`` n'est pas postérieur à ``start``.
|
||||||
|
"""
|
||||||
|
if self.end <= self.start:
|
||||||
|
raise ValueError("end doit être postérieur à start")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class SchoolEventKind(StrEnum):
|
class SchoolEventKind(StrEnum):
|
||||||
"""Type d'événement scolaire."""
|
"""Type d'événement scolaire."""
|
||||||
@@ -75,6 +89,18 @@ class SchoolEvent(BaseModel):
|
|||||||
from_date: _date = Field(..., description="Date de début (inclusive)")
|
from_date: _date = Field(..., description="Date de début (inclusive)")
|
||||||
to_date: _date = Field(..., description="Date de fin (exclusive)")
|
to_date: _date = Field(..., description="Date de fin (exclusive)")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_date_order(self) -> SchoolEvent:
|
||||||
|
"""Valide que la date de fin est postérieure ou égale à la date de début.
|
||||||
|
|
||||||
|
:return: L'instance validée.
|
||||||
|
:rtype: SchoolEvent
|
||||||
|
:raises ValueError: Si ``to_date`` est antérieur à ``from_date``.
|
||||||
|
"""
|
||||||
|
if self.to_date < self.from_date:
|
||||||
|
raise ValueError("to_date doit être postérieur ou égal à from_date")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TheoreticalLesson(BaseModel):
|
class TheoreticalLesson(BaseModel):
|
||||||
"""Représente un cours dans l'agenda théorique."""
|
"""Représente un cours dans l'agenda théorique."""
|
||||||
@@ -82,9 +108,23 @@ class TheoreticalLesson(BaseModel):
|
|||||||
model_config = ConfigDict(frozen=True)
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
id: str = Field(..., description="Identifiant unique")
|
id: str = Field(..., description="Identifiant unique")
|
||||||
day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)")
|
day_of_week: int = Field(
|
||||||
|
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
|
||||||
|
)
|
||||||
start_time: _time = Field(..., description="Heure de début")
|
start_time: _time = Field(..., description="Heure de début")
|
||||||
end_time: _time = Field(..., description="Heure de fin")
|
end_time: _time = Field(..., description="Heure de fin")
|
||||||
subject: str = Field(..., description="Matière")
|
subject: str = Field(..., description="Matière")
|
||||||
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
|
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
||||||
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
|
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_time_order(self) -> TheoreticalLesson:
|
||||||
|
"""Valide que l'heure de fin est postérieure à l'heure de début.
|
||||||
|
|
||||||
|
:return: L'instance validée.
|
||||||
|
:rtype: TheoreticalLesson
|
||||||
|
:raises ValueError: Si ``end_time`` n'est pas postérieur à ``start_time``.
|
||||||
|
"""
|
||||||
|
if self.end_time <= self.start_time:
|
||||||
|
raise ValueError("end_time doit être postérieur à start_time")
|
||||||
|
return self
|
||||||
|
|||||||
@@ -26,17 +26,22 @@ class BlogArticle(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ExternalInfo(BaseModel):
|
class ExternalInfo(BaseModel):
|
||||||
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP."""
|
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP.
|
||||||
|
|
||||||
blog_articles: list[BlogArticle] = Field(
|
Modèle figé (``frozen``) : les instances sont immuables après création.
|
||||||
default_factory=list,
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
blog_articles: tuple[BlogArticle, ...] = Field(
|
||||||
|
default=(),
|
||||||
description="Liste des nouveaux articles du blog",
|
description="Liste des nouveaux articles du blog",
|
||||||
)
|
)
|
||||||
pronote_messages: list[Message] = Field(
|
pronote_messages: tuple[Message, ...] = Field(
|
||||||
default_factory=list,
|
default=(),
|
||||||
description="Liste des messages Pronote",
|
description="Liste des messages Pronote",
|
||||||
)
|
)
|
||||||
other_info: list[str] = Field(
|
other_info: tuple[str, ...] = Field(
|
||||||
default_factory=list,
|
default=(),
|
||||||
description="Autres informations (extensible)",
|
description="Autres informations (extensible)",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
|
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
|
||||||
|
|
||||||
@@ -30,6 +30,22 @@ class AgendaChange(BaseModel):
|
|||||||
)
|
)
|
||||||
details: str = Field(default="", description="Détails du changement")
|
details: str = Field(default="", description="Détails du changement")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_payload_consistency(self) -> AgendaChange:
|
||||||
|
"""Valide la cohérence entre le type de changement et le payload.
|
||||||
|
|
||||||
|
:return: L'instance validée.
|
||||||
|
:rtype: AgendaChange
|
||||||
|
:raises ValueError: Si le payload ne correspond pas au type de changement.
|
||||||
|
"""
|
||||||
|
if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED):
|
||||||
|
if self.lesson is None:
|
||||||
|
raise ValueError(f"lesson est requis pour le type {self.type!r}")
|
||||||
|
if self.type == AgendaChangeType.REMOVED:
|
||||||
|
if self.theoretical_lesson is None:
|
||||||
|
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class AgendaDiff(BaseModel):
|
class AgendaDiff(BaseModel):
|
||||||
"""Représente les différences entre l'agenda réel et l'agenda théorique."""
|
"""Représente les différences entre l'agenda réel et l'agenda théorique."""
|
||||||
@@ -37,4 +53,4 @@ class AgendaDiff(BaseModel):
|
|||||||
model_config = ConfigDict(frozen=True)
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
target_date: date = Field(..., description="Date cible de la comparaison")
|
target_date: date = Field(..., description="Date cible de la comparaison")
|
||||||
changes: list[AgendaChange] = Field(default_factory=list, description="Liste des changements")
|
changes: tuple[AgendaChange, ...] = Field(default=(), description="Liste des changements")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class Homework(BaseModel):
|
|||||||
|
|
||||||
id: str = Field(..., description="ID stable (hachage)")
|
id: str = Field(..., description="ID stable (hachage)")
|
||||||
subject: str = Field(..., description="Matière")
|
subject: str = Field(..., description="Matière")
|
||||||
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
|
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
||||||
assigned_on: date | None = Field(None, description="Date de distribution")
|
assigned_on: date | None = Field(None, description="Date de distribution")
|
||||||
due_on: date = Field(..., description="Date d'échéance")
|
due_on: date = Field(..., description="Date d'échéance")
|
||||||
text: str = Field(..., description="Texte du devoir (brut)")
|
text: str = Field(..., description="Texte du devoir (brut)")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class CalDAVSyncResult(BaseModel):
|
|||||||
"""Résultat d'une synchronisation CalDAV (mutable)."""
|
"""Résultat d'une synchronisation CalDAV (mutable)."""
|
||||||
|
|
||||||
status: CalDAVSyncStatus = Field(..., description="Statut global")
|
status: CalDAVSyncStatus = Field(..., description="Statut global")
|
||||||
added: int = Field(0, description="Nombre d'événements ajoutés")
|
added: int = Field(0, ge=0, description="Nombre d'événements ajoutés")
|
||||||
updated: int = Field(0, description="Nombre d'événements mis à jour")
|
updated: int = Field(0, ge=0, description="Nombre d'événements mis à jour")
|
||||||
removed: int = Field(0, description="Nombre d'événements supprimés")
|
removed: int = Field(0, ge=0, description="Nombre d'événements supprimés")
|
||||||
errors: list[str] = Field(default_factory=list, description="Liste des erreurs")
|
errors: list[str] = Field(default_factory=list, description="Liste des erreurs")
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ class XmppMessage(BaseModel):
|
|||||||
|
|
||||||
target_date: date = Field(..., description="Date cible")
|
target_date: date = Field(..., description="Date cible")
|
||||||
synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)")
|
synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)")
|
||||||
homeworks: list[Homework] = Field(default_factory=list, description="Liste brute des devoirs")
|
homeworks: tuple[Homework, ...] = Field(default=(), description="Liste brute des devoirs")
|
||||||
changes: list[AgendaChange] = Field(
|
changes: tuple[AgendaChange, ...] = Field(
|
||||||
default_factory=list, description="Liste des changements d'agenda"
|
default=(), description="Liste des changements d'agenda"
|
||||||
)
|
|
||||||
messages: list[Message] = Field(
|
|
||||||
default_factory=list, description="Liste des messages/informations"
|
|
||||||
)
|
)
|
||||||
|
messages: tuple[Message, ...] = Field(default=(), description="Liste des messages/informations")
|
||||||
external_info: ExternalInfo | None = Field(
|
external_info: ExternalInfo | None = Field(
|
||||||
None, description="Informations externes (blog, messages Pronote)"
|
None, description="Informations externes (blog, messages Pronote)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,26 +22,15 @@ class RedactingFormatter(logging.Formatter):
|
|||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
"""Formate un enregistrement de log en masquant les secrets.
|
"""Formate un enregistrement de log en masquant les secrets.
|
||||||
|
|
||||||
Le message et chaque argument textuel de l'enregistrement sont rédigés
|
La rédaction est appliquée à la chaîne finale (message, arguments et
|
||||||
avant le formatage final effectué par :class:`logging.Formatter`.
|
traceback inclus) produite par :class:`logging.Formatter`.
|
||||||
|
|
||||||
:param record: Enregistrement de log à formater.
|
:param record: Enregistrement de log à formater.
|
||||||
:return: Message formaté, avec les secrets remplacés par ``REDACTED``.
|
:return: Message formaté, avec les secrets remplacés par ``REDACTED``.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
record.msg = redact_secrets(str(record.msg))
|
formatted = super().format(record)
|
||||||
args = record.args
|
return redact_secrets(formatted)
|
||||||
if args:
|
|
||||||
if isinstance(args, tuple):
|
|
||||||
record.args = tuple(
|
|
||||||
redact_secrets(arg) if isinstance(arg, str) else arg for arg in args
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
record.args = {
|
|
||||||
key: redact_secrets(value) if isinstance(value, str) else value
|
|
||||||
for key, value in args.items()
|
|
||||||
}
|
|
||||||
return super().format(record)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(level: str = "INFO") -> None:
|
def setup_logging(level: str = "INFO") -> None:
|
||||||
|
|||||||
@@ -10,10 +10,30 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
_SENSITIVE_QUERY_KEYS = frozenset({"icalsecurise", "token", "key", "password", "secret"})
|
_SENSITIVE_QUERY_KEYS = frozenset(
|
||||||
_URL_PATTERN = re.compile(r"https?://[^\s]+")
|
{
|
||||||
|
"icalsecurise",
|
||||||
|
"token",
|
||||||
|
"key",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"access_token",
|
||||||
|
"auth",
|
||||||
|
"authorization",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
|
||||||
|
_AUTH_HEADER_PATTERN = re.compile(
|
||||||
|
r"((?:Proxy-)?Authorization)\s*[:=]\s*\S[^\r\n]*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
_ISOLATED_SECRET_PATTERN = re.compile(
|
_ISOLATED_SECRET_PATTERN = re.compile(
|
||||||
r"\b(icalsecurise|token|password|secret|key)\s*=\s*[^\s&]+",
|
r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
|
||||||
|
r"(\s*['\"]?\s*[:=]\s*)"
|
||||||
|
r"(['\"]?)"
|
||||||
|
r"([^\s&'\"]+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_REDACTED = "REDACTED"
|
_REDACTED = "REDACTED"
|
||||||
@@ -21,15 +41,28 @@ _REDACTED_URL = "REDACTED_URL"
|
|||||||
|
|
||||||
|
|
||||||
def redact_url(url: str) -> str:
|
def redact_url(url: str) -> str:
|
||||||
"""Masque les paramètres sensibles dans une URL.
|
"""Masque les identifiants et les paramètres sensibles d'une URL.
|
||||||
|
|
||||||
:param url: URL pouvant contenir des paramètres sensibles (ex: ``icalsecurise``).
|
Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
|
||||||
:return: URL avec les paramètres sensibles remplacés par ``REDACTED``,
|
sont entièrement masquées (utilisateur et mot de passe), ainsi que les
|
||||||
|
paramètres sensibles de la requête (ex: ``icalsecurise``).
|
||||||
|
|
||||||
|
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
|
||||||
|
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
|
||||||
ou ``REDACTED_URL`` si le traitement échoue.
|
ou ``REDACTED_URL`` si le traitement échoue.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parts = urlsplit(url)
|
parts = urlsplit(url)
|
||||||
|
if parts.username is not None or parts.password is not None:
|
||||||
|
# Netloc sûr : REDACTED@hôte:port. L'utilisateur et le mot de
|
||||||
|
# passe sont entièrement masqués.
|
||||||
|
host = parts.hostname or ""
|
||||||
|
if parts.port is not None:
|
||||||
|
netloc = f"{_REDACTED}@{host}:{parts.port}"
|
||||||
|
else:
|
||||||
|
netloc = f"{_REDACTED}@{host}"
|
||||||
|
parts = parts._replace(netloc=netloc)
|
||||||
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
|
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
|
||||||
redacted_query = [
|
redacted_query = [
|
||||||
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
|
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
|
||||||
@@ -43,16 +76,19 @@ def redact_url(url: str) -> str:
|
|||||||
def redact_secrets(text: str) -> str:
|
def redact_secrets(text: str) -> str:
|
||||||
"""Masque les secrets présents dans un texte arbitraire.
|
"""Masque les secrets présents dans un texte arbitraire.
|
||||||
|
|
||||||
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations
|
Les URLs sont d'abord traitées par :func:`redact_url`, puis les en-têtes
|
||||||
isolées de type ``cle=valeur`` (ex: ``icalsecurise=XXX``) sont masquées,
|
d'authentification (``Authorization``, ``Proxy-Authorization``) et les
|
||||||
sans distinction de casse.
|
affectations isolées de type ``cle=valeur`` ou ``cle:valeur``
|
||||||
|
(ex: ``icalsecurise=XXX``, ``"token": "XXX"``) sont masquées, sans
|
||||||
|
distinction de casse.
|
||||||
|
|
||||||
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
||||||
:return: Texte avec les secrets remplacés par ``REDACTED``.
|
:return: Texte avec les secrets remplacés par ``REDACTED``.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
||||||
return _ISOLATED_SECRET_PATTERN.sub(r"\1=REDACTED", redacted)
|
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
|
||||||
|
return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
|
||||||
|
|
||||||
|
|
||||||
def redact_exception(exc: Exception) -> str:
|
def redact_exception(exc: Exception) -> str:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ name = "pronote-sync"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Synchronisation Pronote → CalDAV + XMPP"
|
description = "Synchronisation Pronote → CalDAV + XMPP"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13.5"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Votre Nom", email = "votre@email.com"}
|
{name = "Votre Nom", email = "votre@email.com"}
|
||||||
]
|
]
|
||||||
|
|||||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
119
tests/unit/test_config.py
Normal file
119
tests/unit/test_config.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Tests unitaires pour la configuration et le chargement des secrets.
|
||||||
|
|
||||||
|
Ce module vérifie que la configuration est chargée correctement depuis les
|
||||||
|
variables d'environnement, que les secrets sont masqués dans les représentations
|
||||||
|
textuelles et sérialisées, et que le rechargement fonctionne comme attendu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from pronote_sync.config.env import load_settings
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from _pytest.monkeypatch import MonkeyPatch
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_prefix_loading(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que les variables d'environnement avec préfixe sont chargées correctement.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("PRONOTE_USERNAME", "testuser")
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.pronote.username == "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_reload_via_load_settings(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que le rechargement de la configuration lit les nouvelles valeurs.
|
||||||
|
|
||||||
|
Ce test garantit que les champs configurés avec ``default_factory`` créent
|
||||||
|
des instances fraîches à chaque appel de ``load_settings()``, permettant ainsi
|
||||||
|
un rechargement dynamique des variables d'environnement.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Premier chargement (valeur par défaut None)
|
||||||
|
settings1 = load_settings()
|
||||||
|
assert settings1.pronote.username is None
|
||||||
|
|
||||||
|
# Modification de l'environnement
|
||||||
|
monkeypatch.setenv("PRONOTE_USERNAME", "newuser")
|
||||||
|
|
||||||
|
# Rechargement
|
||||||
|
settings2 = load_settings()
|
||||||
|
assert settings2.pronote.username == "newuser"
|
||||||
|
|
||||||
|
|
||||||
|
def test_secretstr_masking_ical_url() -> None:
|
||||||
|
"""Vérifie que l'URL iCal contenant un secret est masquée dans les représentations.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
ical_url = "https://example.fr/ical?icalsecurise=SECRET_TOKEN" # pragma: allowlist secret
|
||||||
|
settings = Settings(pronote=PronoteSettings(ical_url=SecretStr(ical_url)))
|
||||||
|
|
||||||
|
# Vérification de la représentation string
|
||||||
|
str_repr = str(settings)
|
||||||
|
assert "SECRET_TOKEN" not in str_repr
|
||||||
|
assert "**********" in str_repr
|
||||||
|
|
||||||
|
# Vérification de la représentation repr
|
||||||
|
repr_repr = repr(settings)
|
||||||
|
assert "SECRET_TOKEN" not in repr_repr
|
||||||
|
assert "**********" in repr_repr
|
||||||
|
|
||||||
|
# Vérification de la sérialisation JSON
|
||||||
|
json_str = settings.model_dump_json()
|
||||||
|
assert "SECRET_TOKEN" not in json_str
|
||||||
|
assert "**********" in json_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_secretstr_masking_password() -> None:
|
||||||
|
"""Vérifie que le mot de passe est masqué dans les représentations.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
password = "mypass123" # pragma: allowlist secret
|
||||||
|
settings = Settings(pronote=PronoteSettings(password=SecretStr(password)))
|
||||||
|
|
||||||
|
# Vérification de la représentation string
|
||||||
|
str_repr = str(settings)
|
||||||
|
assert "mypass123" not in str_repr
|
||||||
|
assert "**********" in str_repr
|
||||||
|
|
||||||
|
# Vérification de la représentation repr
|
||||||
|
repr_repr = repr(settings)
|
||||||
|
assert "mypass123" not in repr_repr
|
||||||
|
assert "**********" in repr_repr
|
||||||
|
|
||||||
|
# Vérification de la sérialisation JSON
|
||||||
|
json_str = settings.model_dump_json()
|
||||||
|
assert "mypass123" not in json_str
|
||||||
|
assert "**********" in json_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_singleton_import() -> None:
|
||||||
|
"""Vérifie que l'import du singleton settings lève une ImportError.
|
||||||
|
|
||||||
|
Ce test garantit que le pattern singleton a été supprimé et que la
|
||||||
|
configuration doit être chargée explicitement via ``load_settings()``.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
with pytest.raises(ImportError):
|
||||||
|
# Attempt to import a non-existent attribute to trigger ImportError
|
||||||
|
# as required by the spec
|
||||||
|
from pronote_sync.config.settings import ( # type: ignore[attr-defined] # noqa: F401
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure trailing newline
|
||||||
373
tests/unit/test_models_construction.py
Normal file
373
tests/unit/test_models_construction.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"""Tests de construction et sérialisation JSON des modèles Pydantic.
|
||||||
|
|
||||||
|
Ce module vérifie que chaque modèle figé peut être sérialisé en JSON puis désérialisé
|
||||||
|
sans perte de données, garantissant la stabilité des contrats d'échange.
|
||||||
|
|
||||||
|
Les tests utilisent des données minimales valides extraites des définitions de modèles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from pronote_sync.models.agenda import (
|
||||||
|
HomeworkBlock,
|
||||||
|
Lesson,
|
||||||
|
LessonStatus,
|
||||||
|
SchoolEvent,
|
||||||
|
SchoolEventKind,
|
||||||
|
TheoreticalLesson,
|
||||||
|
)
|
||||||
|
from pronote_sync.models.blog import BlogArticle, ExternalInfo
|
||||||
|
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
|
||||||
|
from pronote_sync.models.homework import Homework
|
||||||
|
from pronote_sync.models.message import Message, MessageType
|
||||||
|
from pronote_sync.models.xmpp import XmppMessage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"model_class, kwargs",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
Lesson,
|
||||||
|
{
|
||||||
|
"id": "lesson-001",
|
||||||
|
"start": datetime(2024, 9, 6, 8, 0, 0),
|
||||||
|
"end": datetime(2024, 9, 6, 9, 30, 0),
|
||||||
|
"subject": "Mathématiques",
|
||||||
|
"teachers": ("M. Dupont",),
|
||||||
|
"rooms": ("Salle 101",),
|
||||||
|
"group": "Classe A",
|
||||||
|
"status": LessonStatus.NORMAL,
|
||||||
|
"content": "Algèbre linéaire",
|
||||||
|
"homework_blocks": (
|
||||||
|
HomeworkBlock(kind="due", date=date(2024, 9, 13), text="Exercice 42"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
TheoreticalLesson,
|
||||||
|
{
|
||||||
|
"id": "theo-lesson-001",
|
||||||
|
"day_of_week": 0, # Lundi
|
||||||
|
"start_time": time(8, 0, 0),
|
||||||
|
"end_time": time(9, 30, 0),
|
||||||
|
"subject": "Mathématiques",
|
||||||
|
"teachers": ("M. Dupont",),
|
||||||
|
"rooms": ("Salle 101",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SchoolEvent,
|
||||||
|
{
|
||||||
|
"kind": SchoolEventKind.HOLIDAY,
|
||||||
|
"label": "Vacances de la Toussaint",
|
||||||
|
"from_date": date(2024, 10, 19),
|
||||||
|
"to_date": date(2024, 11, 3),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Homework,
|
||||||
|
{
|
||||||
|
"id": "hw-001",
|
||||||
|
"subject": "Mathématiques",
|
||||||
|
"teachers": ("M. Dupont",),
|
||||||
|
"assigned_on": date(2024, 9, 1),
|
||||||
|
"due_on": date(2024, 9, 15),
|
||||||
|
"text": "Exercice 42 à rendre",
|
||||||
|
"html": "<p>Exercice 42 à rendre</p>",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AgendaChange,
|
||||||
|
{
|
||||||
|
"type": AgendaChangeType.ADDED,
|
||||||
|
"lesson": Lesson(
|
||||||
|
id="lesson-002",
|
||||||
|
start=datetime(2024, 9, 7, 10, 0, 0),
|
||||||
|
end=datetime(2024, 9, 7, 11, 30, 0),
|
||||||
|
subject="Physique",
|
||||||
|
teachers=("M. Martin",),
|
||||||
|
rooms=("Labo 205",),
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
),
|
||||||
|
"theoretical_lesson": None,
|
||||||
|
"details": "Cours ajouté exceptionnellement",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AgendaDiff,
|
||||||
|
{
|
||||||
|
"target_date": date(2024, 9, 6),
|
||||||
|
"changes": (
|
||||||
|
AgendaChange(
|
||||||
|
type=AgendaChangeType.ADDED,
|
||||||
|
lesson=Lesson(
|
||||||
|
id="lesson-003",
|
||||||
|
start=datetime(2024, 9, 8, 14, 0, 0),
|
||||||
|
end=datetime(2024, 9, 8, 15, 30, 0),
|
||||||
|
subject="Histoire",
|
||||||
|
teachers=("Mme Bernard",),
|
||||||
|
rooms=("Salle 201",),
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
),
|
||||||
|
theoretical_lesson=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
XmppMessage,
|
||||||
|
{
|
||||||
|
"target_date": date(2024, 9, 6),
|
||||||
|
"synthesis": "Aucun changement majeur aujourd'hui.",
|
||||||
|
"homeworks": (
|
||||||
|
Homework(
|
||||||
|
id="hw-002",
|
||||||
|
subject="Physique",
|
||||||
|
teachers=("M. Martin",),
|
||||||
|
assigned_on=None,
|
||||||
|
due_on=date(2024, 9, 7),
|
||||||
|
text="TP à préparer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"changes": (
|
||||||
|
AgendaChange(
|
||||||
|
type=AgendaChangeType.MODIFIED,
|
||||||
|
lesson=Lesson(
|
||||||
|
id="lesson-004",
|
||||||
|
start=datetime(2024, 9, 6, 16, 0, 0),
|
||||||
|
end=datetime(2024, 9, 6, 17, 30, 0),
|
||||||
|
subject="SVT",
|
||||||
|
teachers=("M. Lefèvre",),
|
||||||
|
rooms=("Salle 302",),
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
),
|
||||||
|
theoretical_lesson=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"messages": (
|
||||||
|
Message(
|
||||||
|
id="msg-001",
|
||||||
|
type=MessageType.INFORMATION,
|
||||||
|
title="Rappel",
|
||||||
|
content="Réunion parents-professeurs demain",
|
||||||
|
author="Secrétariat",
|
||||||
|
date=datetime(2024, 9, 5, 10, 0, 0),
|
||||||
|
read=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"external_info": ExternalInfo(blog_articles=(), pronote_messages=(), other_info=()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
BlogArticle,
|
||||||
|
{
|
||||||
|
"id": "article-001",
|
||||||
|
"title": "Sortie scolaire à Paris",
|
||||||
|
"url": "https://college.fr/blog/sortie-paris",
|
||||||
|
"published_at": datetime(2024, 9, 5, 14, 30, 0),
|
||||||
|
"updated_at": datetime(2024, 9, 5, 16, 45, 0),
|
||||||
|
"category": "Actualités",
|
||||||
|
"author": "M. Dubois",
|
||||||
|
"content_html": "<p>Sortie prévue le 15 octobre</p>",
|
||||||
|
"content_text": "Sortie prévue le 15 octobre",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{
|
||||||
|
"blog_articles": (
|
||||||
|
BlogArticle(
|
||||||
|
id="article-002",
|
||||||
|
title="Nouveau règlement intérieur",
|
||||||
|
url="https://college.fr/blog/reglement",
|
||||||
|
published_at=datetime(2024, 9, 1, 9, 0, 0),
|
||||||
|
updated_at=None,
|
||||||
|
category=None,
|
||||||
|
author=None,
|
||||||
|
content_html="<p>Mise à jour du règlement</p>",
|
||||||
|
content_text="Mise à jour du règlement",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"pronote_messages": (
|
||||||
|
Message(
|
||||||
|
id="msg-002",
|
||||||
|
type=MessageType.DISCUSSION,
|
||||||
|
title="Devoirs de maths",
|
||||||
|
content="Voir Pronote pour les détails",
|
||||||
|
author="Prof Maths",
|
||||||
|
date=datetime(2024, 9, 4, 8, 0, 0),
|
||||||
|
read=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"other_info": ("Info complémentaire 1", "Info complémentaire 2"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Message,
|
||||||
|
{
|
||||||
|
"id": "msg-003",
|
||||||
|
"type": MessageType.SURVEY,
|
||||||
|
"title": "Enquête de satisfaction",
|
||||||
|
"content": "Merci de répondre au questionnaire",
|
||||||
|
"author": "Direction",
|
||||||
|
"date": datetime(2024, 9, 5, 11, 0, 0),
|
||||||
|
"read": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_model_json_roundtrip(model_class: type[BaseModel], kwargs: dict[str, Any]) -> None:
|
||||||
|
"""Vérifie qu'un modèle peut être sérialisé en JSON puis désérialisé sans perte.
|
||||||
|
|
||||||
|
:param model_class: Classe du modèle à tester.
|
||||||
|
:type model_class: type[BaseModel]
|
||||||
|
:param kwargs: Arguments de constructeur valides.
|
||||||
|
:type kwargs: dict[str, Any]
|
||||||
|
"""
|
||||||
|
# Construction de l'instance
|
||||||
|
instance = model_class(**kwargs)
|
||||||
|
|
||||||
|
# Sérialisation JSON
|
||||||
|
json_str = instance.model_dump_json()
|
||||||
|
|
||||||
|
# Désérialisation depuis JSON
|
||||||
|
roundtrip_instance = model_class.model_validate_json(json_str)
|
||||||
|
|
||||||
|
# Vérification des champs clés (comparaison par valeur)
|
||||||
|
for field_name in kwargs:
|
||||||
|
if field_name in (
|
||||||
|
"start",
|
||||||
|
"end",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"published_at",
|
||||||
|
"updated_at",
|
||||||
|
"date",
|
||||||
|
"due_on",
|
||||||
|
"assigned_on",
|
||||||
|
"generated_at",
|
||||||
|
):
|
||||||
|
# Pour les dates/heures, comparer les objets directement
|
||||||
|
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
|
||||||
|
f"Le champ {field_name} ne correspond pas après round-trip"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Pour les autres champs, comparer directement
|
||||||
|
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
|
||||||
|
f"Le champ {field_name} ne correspond pas après round-trip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_status_enum_values() -> None:
|
||||||
|
"""Vérifie que les valeurs de l'enum LessonStatus sont correctes.
|
||||||
|
|
||||||
|
:raises ValidationError: Si une valeur invalide est utilisée.
|
||||||
|
"""
|
||||||
|
# Test des valeurs valides
|
||||||
|
for status in [LessonStatus.NORMAL, LessonStatus.CANCELLED, LessonStatus.MOVED]:
|
||||||
|
assert isinstance(status.value, str)
|
||||||
|
Lesson(
|
||||||
|
id="test",
|
||||||
|
start=datetime(2024, 9, 6, 8, 0),
|
||||||
|
end=datetime(2024, 9, 6, 9, 0),
|
||||||
|
subject="Test",
|
||||||
|
status=status,
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_school_event_kind_enum_values() -> None:
|
||||||
|
"""Vérifie que les valeurs de l'enum SchoolEventKind sont correctes.
|
||||||
|
|
||||||
|
:raises ValidationError: Si une valeur invalide est utilisée.
|
||||||
|
"""
|
||||||
|
# Test des valeurs valides
|
||||||
|
for kind in [SchoolEventKind.HOLIDAY, SchoolEventKind.PUBLIC_HOLIDAY]:
|
||||||
|
assert isinstance(kind.value, str)
|
||||||
|
SchoolEvent(
|
||||||
|
kind=kind,
|
||||||
|
label="Test",
|
||||||
|
from_date=date(2024, 9, 1),
|
||||||
|
to_date=date(2024, 9, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_type_enum_values() -> None:
|
||||||
|
"""Vérifie que les valeurs de l'enum MessageType sont correctes.
|
||||||
|
|
||||||
|
:raises ValidationError: Si une valeur invalide est utilisée.
|
||||||
|
"""
|
||||||
|
# Test des valeurs valides
|
||||||
|
for msg_type in [MessageType.DISCUSSION, MessageType.INFORMATION, MessageType.SURVEY]:
|
||||||
|
assert isinstance(msg_type.value, str)
|
||||||
|
Message(
|
||||||
|
id="test",
|
||||||
|
type=msg_type,
|
||||||
|
title="Test",
|
||||||
|
content="Test",
|
||||||
|
author="Test",
|
||||||
|
date=datetime(2024, 9, 1, 8, 0),
|
||||||
|
read=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agenda_change_type_enum_values() -> None:
|
||||||
|
"""Vérifie que les valeurs de l'enum AgendaChangeType sont correctes.
|
||||||
|
|
||||||
|
:raises ValidationError: Si une valeur invalide est utilisée.
|
||||||
|
"""
|
||||||
|
# Test des valeurs valides
|
||||||
|
for change_type in [
|
||||||
|
AgendaChangeType.ADDED,
|
||||||
|
AgendaChangeType.REMOVED,
|
||||||
|
AgendaChangeType.MODIFIED,
|
||||||
|
]:
|
||||||
|
assert isinstance(change_type.value, str)
|
||||||
|
if change_type == AgendaChangeType.ADDED:
|
||||||
|
AgendaChange(
|
||||||
|
type=change_type,
|
||||||
|
lesson=Lesson(
|
||||||
|
id="test",
|
||||||
|
start=datetime(2024, 9, 6, 8, 0),
|
||||||
|
end=datetime(2024, 9, 6, 9, 0),
|
||||||
|
subject="Test",
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
),
|
||||||
|
theoretical_lesson=None,
|
||||||
|
)
|
||||||
|
elif change_type == AgendaChangeType.REMOVED:
|
||||||
|
AgendaChange(
|
||||||
|
type=change_type,
|
||||||
|
lesson=None,
|
||||||
|
theoretical_lesson=TheoreticalLesson(
|
||||||
|
id="test",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time=time(8, 0, 0),
|
||||||
|
end_time=time(9, 0, 0),
|
||||||
|
subject="Test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
AgendaChange(
|
||||||
|
type=change_type,
|
||||||
|
lesson=Lesson(
|
||||||
|
id="test",
|
||||||
|
start=datetime(2024, 9, 6, 8, 0),
|
||||||
|
end=datetime(2024, 9, 6, 9, 0),
|
||||||
|
subject="Test",
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
),
|
||||||
|
theoretical_lesson=None,
|
||||||
|
)
|
||||||
409
tests/unit/test_models_immutability.py
Normal file
409
tests/unit/test_models_immutability.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
"""Tests d'immuabilité des modèles Pydantic figés.
|
||||||
|
|
||||||
|
Ce module vérifie que les modèles marqués comme figés (frozen=True) ne peuvent pas
|
||||||
|
être modifiés après construction, et que leurs collections sont bien des tuples.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus
|
||||||
|
from pronote_sync.models.blog import BlogArticle, ExternalInfo
|
||||||
|
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
|
||||||
|
from pronote_sync.models.homework import Homework
|
||||||
|
from pronote_sync.models.message import Message, MessageType
|
||||||
|
from pronote_sync.models.pronote import PronoteData
|
||||||
|
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
|
||||||
|
from pronote_sync.models.xmpp import XmppMessage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"model_class, kwargs",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
Lesson,
|
||||||
|
{
|
||||||
|
"id": "lesson-immutability",
|
||||||
|
"start": datetime(2024, 9, 6, 8, 0, 0),
|
||||||
|
"end": datetime(2024, 9, 6, 9, 30, 0),
|
||||||
|
"subject": "Mathématiques",
|
||||||
|
"teachers": ("M. Dupont", "Mme Martin"),
|
||||||
|
"rooms": ("Salle 101",),
|
||||||
|
"status": LessonStatus.NORMAL,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Homework,
|
||||||
|
{
|
||||||
|
"id": "hw-immutability",
|
||||||
|
"subject": "Physique",
|
||||||
|
"teachers": ("M. Martin",),
|
||||||
|
"due_on": date(2024, 9, 15),
|
||||||
|
"text": "Exercice à rendre",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AgendaChange,
|
||||||
|
{
|
||||||
|
"type": AgendaChangeType.ADDED,
|
||||||
|
"lesson": Lesson(
|
||||||
|
id="lesson-ref",
|
||||||
|
start=datetime(2024, 9, 6, 10, 0, 0),
|
||||||
|
end=datetime(2024, 9, 6, 11, 30, 0),
|
||||||
|
subject="Histoire",
|
||||||
|
teachers=("Mme Bernard",),
|
||||||
|
rooms=("Salle 201",),
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
XmppMessage,
|
||||||
|
{
|
||||||
|
"target_date": date(2024, 9, 6),
|
||||||
|
"synthesis": "Test immuabilité",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
HomeworkBlock,
|
||||||
|
{
|
||||||
|
"kind": "due",
|
||||||
|
"date": date(2024, 9, 15),
|
||||||
|
"text": "Devoir à faire",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{
|
||||||
|
"blog_articles": (
|
||||||
|
BlogArticle(
|
||||||
|
id="article-1",
|
||||||
|
title="Article 1",
|
||||||
|
url="https://example.com/1",
|
||||||
|
published_at=datetime(2024, 9, 1, 8, 0, 0),
|
||||||
|
updated_at=None,
|
||||||
|
category=None,
|
||||||
|
author=None,
|
||||||
|
content_html="<p>Contenu</p>",
|
||||||
|
content_text="Contenu",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"pronote_messages": (
|
||||||
|
Message(
|
||||||
|
id="msg-1",
|
||||||
|
type=MessageType.INFORMATION,
|
||||||
|
title="Message 1",
|
||||||
|
content="Message",
|
||||||
|
author="Auteur",
|
||||||
|
date=datetime(2024, 9, 1, 8, 0, 0),
|
||||||
|
read=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_frozen_model_rejects_field_assignment(
|
||||||
|
model_class: type[BaseModel], kwargs: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un modèle figé rejette toute tentative d'assignation de champ.
|
||||||
|
|
||||||
|
:param model_class: Classe du modèle figé.
|
||||||
|
:type model_class: type[BaseModel]
|
||||||
|
:param kwargs: Arguments de constructeur valides.
|
||||||
|
:type kwargs: dict[str, Any]
|
||||||
|
"""
|
||||||
|
instance: Any = model_class(**kwargs)
|
||||||
|
|
||||||
|
# Tentative d'assignation directe sur un champ
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
if model_class == Lesson:
|
||||||
|
instance.subject = "Nouveau sujet"
|
||||||
|
elif model_class == Homework:
|
||||||
|
instance.subject = "Nouveau sujet"
|
||||||
|
elif model_class == AgendaChange:
|
||||||
|
instance.details = "Nouveaux détails"
|
||||||
|
elif model_class == XmppMessage:
|
||||||
|
instance.synthesis = "Nouvelle synthèse"
|
||||||
|
elif model_class == HomeworkBlock:
|
||||||
|
instance.text = "Nouveau texte"
|
||||||
|
elif model_class == ExternalInfo:
|
||||||
|
instance.other_info = ("Nouvelle info",)
|
||||||
|
|
||||||
|
error_msg = str(exc_info.value).lower()
|
||||||
|
assert "instance is frozen" in error_msg or "immutable" in error_msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"model_class, kwargs, field_name, expected_type",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
Lesson,
|
||||||
|
{
|
||||||
|
"id": "lesson-tuple",
|
||||||
|
"start": datetime(2024, 9, 6, 8, 0),
|
||||||
|
"end": datetime(2024, 9, 6, 9, 0),
|
||||||
|
"subject": "Test",
|
||||||
|
},
|
||||||
|
"teachers",
|
||||||
|
tuple,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lesson,
|
||||||
|
{
|
||||||
|
"id": "lesson-tuple2",
|
||||||
|
"start": datetime(2024, 9, 6, 8, 0),
|
||||||
|
"end": datetime(2024, 9, 6, 9, 0),
|
||||||
|
"subject": "Test",
|
||||||
|
},
|
||||||
|
"rooms",
|
||||||
|
tuple,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lesson,
|
||||||
|
{
|
||||||
|
"id": "lesson-tuple3",
|
||||||
|
"start": datetime(2024, 9, 6, 8, 0),
|
||||||
|
"end": datetime(2024, 9, 6, 9, 0),
|
||||||
|
"subject": "Test",
|
||||||
|
"homework_blocks": (),
|
||||||
|
},
|
||||||
|
"homework_blocks",
|
||||||
|
tuple,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Homework,
|
||||||
|
{
|
||||||
|
"id": "hw-tuple",
|
||||||
|
"subject": "Test",
|
||||||
|
"teachers": ("Prof 1",),
|
||||||
|
"due_on": date(2024, 9, 15),
|
||||||
|
"text": "Devoir",
|
||||||
|
},
|
||||||
|
"teachers",
|
||||||
|
tuple,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AgendaChange,
|
||||||
|
{
|
||||||
|
"type": AgendaChangeType.ADDED,
|
||||||
|
"lesson": Lesson(
|
||||||
|
id="ref",
|
||||||
|
start=datetime(2024, 9, 6, 8, 0),
|
||||||
|
end=datetime(2024, 9, 6, 9, 0),
|
||||||
|
subject="Test",
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"details",
|
||||||
|
str,
|
||||||
|
),
|
||||||
|
(XmppMessage, {"target_date": date(2024, 9, 6)}, "homeworks", tuple),
|
||||||
|
(XmppMessage, {"target_date": date(2024, 9, 6)}, "changes", tuple),
|
||||||
|
(XmppMessage, {"target_date": date(2024, 9, 6)}, "messages", tuple),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
|
||||||
|
"blog_articles",
|
||||||
|
tuple,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
|
||||||
|
"pronote_messages",
|
||||||
|
tuple,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
|
||||||
|
"other_info",
|
||||||
|
tuple,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_collection_fields_are_tuples(
|
||||||
|
model_class: type[BaseModel],
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
field_name: str,
|
||||||
|
expected_type: type[Any],
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les champs de collection sont bien des tuples et non des listes.
|
||||||
|
|
||||||
|
:param model_class: Classe du modèle.
|
||||||
|
:type model_class: type[BaseModel]
|
||||||
|
:param kwargs: Arguments de constructeur valides.
|
||||||
|
:type kwargs: dict[str, Any]
|
||||||
|
:param field_name: Nom du champ de collection à vérifier.
|
||||||
|
:type field_name: str
|
||||||
|
:param expected_type: Type attendu (tuple).
|
||||||
|
:type expected_type: type
|
||||||
|
"""
|
||||||
|
instance = model_class(**kwargs)
|
||||||
|
field_value = getattr(instance, field_name)
|
||||||
|
assert isinstance(field_value, expected_type), (
|
||||||
|
f"Le champ {field_name} devrait être de type {expected_type.__name__}, "
|
||||||
|
f"got {type(field_value).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"model_class, kwargs, field_name",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
Lesson,
|
||||||
|
{
|
||||||
|
"id": "lesson-no-append",
|
||||||
|
"start": datetime(2024, 9, 6, 8, 0),
|
||||||
|
"end": datetime(2024, 9, 6, 9, 0),
|
||||||
|
"subject": "Test",
|
||||||
|
},
|
||||||
|
"teachers",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lesson,
|
||||||
|
{
|
||||||
|
"id": "lesson-no-append2",
|
||||||
|
"start": datetime(2024, 9, 6, 8, 0),
|
||||||
|
"end": datetime(2024, 9, 6, 9, 0),
|
||||||
|
"subject": "Test",
|
||||||
|
},
|
||||||
|
"rooms",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Homework,
|
||||||
|
{
|
||||||
|
"id": "hw-no-append",
|
||||||
|
"subject": "Test",
|
||||||
|
"teachers": ("Prof 1",),
|
||||||
|
"due_on": date(2024, 9, 15),
|
||||||
|
"text": "Devoir",
|
||||||
|
},
|
||||||
|
"teachers",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
|
||||||
|
"blog_articles",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
|
||||||
|
"pronote_messages",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalInfo,
|
||||||
|
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
|
||||||
|
"other_info",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_collection_fields_reject_append(
|
||||||
|
model_class: type[BaseModel], kwargs: dict[str, Any], field_name: str
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les champs de collection (tuples) rejettent la méthode append.
|
||||||
|
|
||||||
|
:param model_class: Classe du modèle.
|
||||||
|
:type model_class: type[BaseModel]
|
||||||
|
:param kwargs: Arguments de constructeur valides.
|
||||||
|
:type kwargs: dict[str, Any]
|
||||||
|
:param field_name: Nom du champ de collection.
|
||||||
|
:type field_name: str
|
||||||
|
"""
|
||||||
|
instance = model_class(**kwargs)
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError) as exc_info:
|
||||||
|
collection = getattr(instance, field_name)
|
||||||
|
collection.append("new_item")
|
||||||
|
|
||||||
|
assert "'tuple' object has no attribute 'append'" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"model_class, kwargs, field_name, new_value",
|
||||||
|
[
|
||||||
|
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "added", 5),
|
||||||
|
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "updated", 3),
|
||||||
|
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "removed", 2),
|
||||||
|
(
|
||||||
|
CalDAVSyncResult,
|
||||||
|
{"status": CalDAVSyncStatus.FAILED},
|
||||||
|
"errors",
|
||||||
|
["Erreur 1", "Erreur 2"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_mutable_models_accept_field_assignment(
|
||||||
|
model_class: type[BaseModel],
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
field_name: str,
|
||||||
|
new_value: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les modèles mutables (non figés) acceptent l'assignation de champs.
|
||||||
|
|
||||||
|
:param model_class: Classe du modèle mutable.
|
||||||
|
:type model_class: type[BaseModel]
|
||||||
|
:param kwargs: Arguments de constructeur valides.
|
||||||
|
:type kwargs: dict[str, Any]
|
||||||
|
:param field_name: Nom du champ à assigner.
|
||||||
|
:type field_name: str
|
||||||
|
:param new_value: Nouvelle valeur à assigner.
|
||||||
|
:type new_value: Any
|
||||||
|
"""
|
||||||
|
instance = model_class(**kwargs)
|
||||||
|
|
||||||
|
# Assignation du champ
|
||||||
|
setattr(instance, field_name, new_value)
|
||||||
|
|
||||||
|
# Vérification que la valeur a bien été assignée
|
||||||
|
assert getattr(instance, field_name) == new_value, (
|
||||||
|
f"Le champ {field_name} n'a pas été correctement assigné"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"model_class, kwargs",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
PronoteData,
|
||||||
|
{
|
||||||
|
"target_date": date(2024, 9, 6),
|
||||||
|
"generated_at": datetime(2024, 9, 6, 8, 0, 0),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_mutable_models_reject_frozen_assignment(
|
||||||
|
model_class: type[BaseModel], kwargs: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les modèles mutables peuvent être modifiés mais pas rendus figés.
|
||||||
|
|
||||||
|
Note: Les modèles mutables comme PronoteData et CalDAVSyncResult n'ont pas
|
||||||
|
ConfigDict(frozen=True), donc ils peuvent être modifiés.
|
||||||
|
|
||||||
|
:param model_class: Classe du modèle mutable.
|
||||||
|
:type model_class: type[BaseModel]
|
||||||
|
:param kwargs: Arguments de constructeur valides.
|
||||||
|
:type kwargs: dict[str, Any]
|
||||||
|
"""
|
||||||
|
from pronote_sync.models.pronote import PronoteData
|
||||||
|
|
||||||
|
instance: Any = model_class(**kwargs)
|
||||||
|
|
||||||
|
# Assignation normale doit fonctionner
|
||||||
|
if model_class == PronoteData:
|
||||||
|
instance.lessons.append("dummy")
|
||||||
|
else:
|
||||||
|
instance.added = 10
|
||||||
|
|
||||||
|
# Vérification que l'instance n'est pas figée
|
||||||
|
assert not hasattr(instance.model_config, "frozen") or not instance.model_config.get(
|
||||||
|
"frozen", False
|
||||||
|
)
|
||||||
287
tests/unit/test_models_invariants.py
Normal file
287
tests/unit/test_models_invariants.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""Tests d'invariants et de validation des modèles Pydantic.
|
||||||
|
|
||||||
|
Ce module vérifie que les validateurs de modèle rejettent les données invalides
|
||||||
|
conformément aux spécifications (ex: ordre des dates, plages de valeurs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from pronote_sync.models.agenda import (
|
||||||
|
Lesson,
|
||||||
|
SchoolEvent,
|
||||||
|
SchoolEventKind,
|
||||||
|
TheoreticalLesson,
|
||||||
|
)
|
||||||
|
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
|
||||||
|
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheoreticalLessonDayOfWeek:
|
||||||
|
"""Tests de validation du champ day_of_week pour TheoreticalLesson."""
|
||||||
|
|
||||||
|
def test_day_of_week_7_invalid(self) -> None:
|
||||||
|
"""Vérifie que day_of_week=7 lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TheoreticalLesson(
|
||||||
|
id="test",
|
||||||
|
day_of_week=7,
|
||||||
|
start_time=time(8, 0, 0),
|
||||||
|
end_time=time(9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_day_of_week_minus_1_invalid(self) -> None:
|
||||||
|
"""Vérifie que day_of_week=-1 lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLesson(
|
||||||
|
id="test",
|
||||||
|
day_of_week=-1,
|
||||||
|
start_time=time(8, 0, 0),
|
||||||
|
end_time=time(9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"greater than or equal to 0" in str(error).lower() or "le 6" in str(error).lower()
|
||||||
|
for error in exc_info.value.errors()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_day_of_week_0_valid(self) -> None:
|
||||||
|
"""Vérifie que day_of_week=0 (lundi) est valide."""
|
||||||
|
instance = TheoreticalLesson(
|
||||||
|
id="test",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time=time(8, 0, 0),
|
||||||
|
end_time=time(9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
)
|
||||||
|
assert instance.day_of_week == 0
|
||||||
|
|
||||||
|
def test_day_of_week_6_valid(self) -> None:
|
||||||
|
"""Vérifie que day_of_week=6 (dimanche) est valide."""
|
||||||
|
instance = TheoreticalLesson(
|
||||||
|
id="test",
|
||||||
|
day_of_week=6,
|
||||||
|
start_time=time(8, 0, 0),
|
||||||
|
end_time=time(9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
)
|
||||||
|
assert instance.day_of_week == 6
|
||||||
|
|
||||||
|
|
||||||
|
class TestLessonTimeOrder:
|
||||||
|
"""Tests de validation de l'ordre temporel pour Lesson."""
|
||||||
|
|
||||||
|
def test_lesson_end_before_start_invalid(self) -> None:
|
||||||
|
"""Vérifie que end <= start lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
Lesson(
|
||||||
|
id="test",
|
||||||
|
start=datetime(2024, 9, 6, 10, 0, 0),
|
||||||
|
end=datetime(2024, 9, 6, 9, 0, 0), # end avant start
|
||||||
|
subject="Mathématiques",
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"end doit être postérieur à start" in str(error) for error in exc_info.value.errors()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lesson_end_after_start_valid(self) -> None:
|
||||||
|
"""Vérifie que end > start est valide."""
|
||||||
|
instance = Lesson(
|
||||||
|
id="test",
|
||||||
|
start=datetime(2024, 9, 6, 8, 0, 0),
|
||||||
|
end=datetime(2024, 9, 6, 9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
assert instance.end > instance.start
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheoreticalLessonTimeOrder:
|
||||||
|
"""Tests de validation de l'ordre temporel pour TheoreticalLesson."""
|
||||||
|
|
||||||
|
def test_theoretical_lesson_end_time_before_start_time_invalid(self) -> None:
|
||||||
|
"""Vérifie que end_time <= start_time lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLesson(
|
||||||
|
id="test",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time=time(9, 0, 0),
|
||||||
|
end_time=time(8, 0, 0), # end_time avant start_time
|
||||||
|
subject="Mathématiques",
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"end_time doit être postérieur à start_time" in str(error)
|
||||||
|
for error in exc_info.value.errors()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_theoretical_lesson_end_time_after_start_time_valid(self) -> None:
|
||||||
|
"""Vérifie que end_time > start_time est valide."""
|
||||||
|
instance = TheoreticalLesson(
|
||||||
|
id="test",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time=time(8, 0, 0),
|
||||||
|
end_time=time(9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
)
|
||||||
|
assert instance.end_time > instance.start_time
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchoolEventDateOrder:
|
||||||
|
"""Tests de validation de l'ordre des dates pour SchoolEvent."""
|
||||||
|
|
||||||
|
def test_school_event_to_date_before_from_date_invalid(self) -> None:
|
||||||
|
"""Vérifie que to_date < from_date lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SchoolEvent(
|
||||||
|
kind=SchoolEventKind.HOLIDAY,
|
||||||
|
label="Test",
|
||||||
|
from_date=date(2024, 10, 20),
|
||||||
|
to_date=date(2024, 10, 19), # to_date avant from_date
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"to_date doit être postérieur ou égal à from_date" in str(error)
|
||||||
|
for error in exc_info.value.errors()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_school_event_to_date_equal_from_date_valid(self) -> None:
|
||||||
|
"""Vérifie que to_date == from_date est valide."""
|
||||||
|
instance = SchoolEvent(
|
||||||
|
kind=SchoolEventKind.HOLIDAY,
|
||||||
|
label="Test",
|
||||||
|
from_date=date(2024, 10, 20),
|
||||||
|
to_date=date(2024, 10, 20), # égal
|
||||||
|
)
|
||||||
|
assert instance.to_date >= instance.from_date
|
||||||
|
|
||||||
|
def test_school_event_to_date_after_from_date_valid(self) -> None:
|
||||||
|
"""Vérifie que to_date > from_date est valide."""
|
||||||
|
instance = SchoolEvent(
|
||||||
|
kind=SchoolEventKind.HOLIDAY,
|
||||||
|
label="Test",
|
||||||
|
from_date=date(2024, 10, 20),
|
||||||
|
to_date=date(2024, 10, 25),
|
||||||
|
)
|
||||||
|
assert instance.to_date >= instance.from_date
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgendaChangeConsistency:
|
||||||
|
"""Tests de cohérence des changements d'agenda."""
|
||||||
|
|
||||||
|
def test_agenda_change_added_without_lesson_invalid(self) -> None:
|
||||||
|
"""Vérifie que type=ADDED avec lesson=None lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
AgendaChange(type=AgendaChangeType.ADDED, lesson=None, theoretical_lesson=None)
|
||||||
|
assert any(
|
||||||
|
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agenda_change_removed_without_theoretical_lesson_invalid(self) -> None:
|
||||||
|
"""Vérifie que type=REMOVED avec theoretical_lesson=None lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
AgendaChange(
|
||||||
|
type=AgendaChangeType.REMOVED,
|
||||||
|
lesson=None,
|
||||||
|
theoretical_lesson=None,
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"theoretical_lesson est requis pour le type" in str(error)
|
||||||
|
for error in exc_info.value.errors()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agenda_change_modified_without_lesson_invalid(self) -> None:
|
||||||
|
"""Vérifie que type=MODIFIED avec lesson=None lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
AgendaChange(type=AgendaChangeType.MODIFIED, lesson=None, theoretical_lesson=None)
|
||||||
|
assert any(
|
||||||
|
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agenda_change_added_with_lesson_valid(self) -> None:
|
||||||
|
"""Vérifie que type=ADDED avec lesson=<valide> est valide."""
|
||||||
|
lesson = Lesson(
|
||||||
|
id="lesson-valid",
|
||||||
|
start=datetime(2024, 9, 6, 8, 0, 0),
|
||||||
|
end=datetime(2024, 9, 6, 9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
instance = AgendaChange(
|
||||||
|
type=AgendaChangeType.ADDED,
|
||||||
|
lesson=lesson,
|
||||||
|
theoretical_lesson=None,
|
||||||
|
)
|
||||||
|
assert instance.type == AgendaChangeType.ADDED
|
||||||
|
assert instance.lesson is not None
|
||||||
|
|
||||||
|
def test_agenda_change_removed_with_theoretical_lesson_valid(self) -> None:
|
||||||
|
"""Vérifie que type=REMOVED avec theoretical_lesson=<valide> est valide."""
|
||||||
|
theoretical_lesson = TheoreticalLesson(
|
||||||
|
id="theo-lesson-valid",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time=time(8, 0, 0),
|
||||||
|
end_time=time(9, 30, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
)
|
||||||
|
instance = AgendaChange(
|
||||||
|
type=AgendaChangeType.REMOVED,
|
||||||
|
lesson=None,
|
||||||
|
theoretical_lesson=theoretical_lesson,
|
||||||
|
)
|
||||||
|
assert instance.type == AgendaChangeType.REMOVED
|
||||||
|
assert instance.theoretical_lesson is not None
|
||||||
|
|
||||||
|
def test_agenda_change_modified_with_lesson_valid(self) -> None:
|
||||||
|
"""Vérifie que type=MODIFIED avec lesson=<valide> est valide."""
|
||||||
|
lesson = Lesson(
|
||||||
|
id="lesson-valid-mod",
|
||||||
|
start=datetime(2024, 9, 6, 10, 0, 0),
|
||||||
|
end=datetime(2024, 9, 6, 11, 30, 0),
|
||||||
|
subject="Physique",
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
instance = AgendaChange(
|
||||||
|
type=AgendaChangeType.MODIFIED,
|
||||||
|
lesson=lesson,
|
||||||
|
theoretical_lesson=None,
|
||||||
|
)
|
||||||
|
assert instance.type == AgendaChangeType.MODIFIED
|
||||||
|
assert instance.lesson is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalDAVSyncResultInvariants:
|
||||||
|
"""Tests de validation des champs numériques de CalDAVSyncResult."""
|
||||||
|
|
||||||
|
def test_caldav_sync_result_added_negative_invalid(self) -> None:
|
||||||
|
"""Vérifie que added=-1 lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=-1, updated=0, removed=0)
|
||||||
|
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
|
||||||
|
|
||||||
|
def test_caldav_sync_result_updated_negative_invalid(self) -> None:
|
||||||
|
"""Vérifie que updated=-1 lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=-1, removed=0)
|
||||||
|
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
|
||||||
|
|
||||||
|
def test_caldav_sync_result_removed_negative_invalid(self) -> None:
|
||||||
|
"""Vérifie que removed=-1 lève une ValidationError."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=-1)
|
||||||
|
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
|
||||||
|
|
||||||
|
def test_caldav_sync_result_zero_values_valid(self) -> None:
|
||||||
|
"""Vérifie que les valeurs à zéro sont valides."""
|
||||||
|
instance = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
|
||||||
|
assert instance.added == 0
|
||||||
|
assert instance.updated == 0
|
||||||
|
assert instance.removed == 0
|
||||||
128
tests/unit/test_redaction.py
Normal file
128
tests/unit/test_redaction.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Tests unitaires pour les utilitaires de masquage des secrets.
|
||||||
|
|
||||||
|
Ce module vérifie que les fonctions de masquage des URLs, des en-têtes
|
||||||
|
et des secrets dans les textes fonctionnent correctement, sans fuite
|
||||||
|
d'informations sensibles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_with_sensitive_query_param() -> None:
|
||||||
|
"""Vérifie que les paramètres sensibles dans une URL sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "https://example.fr/ical?icalsecurise=TOKEN123" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "TOKEN123" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_with_embedded_auth() -> None:
|
||||||
|
"""Vérifie que les informations d'authentification intégrées dans une URL sont masquées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "https://user:pass@host/path" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "user" not in redacted
|
||||||
|
assert "pass" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_with_uppercase_scheme() -> None:
|
||||||
|
"""Vérifie que les URLs avec un schéma en majuscules sont traitées correctement.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "HTTPS://user:pass@host/path" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "user" not in redacted
|
||||||
|
assert "pass" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_authorization_bearer() -> None:
|
||||||
|
"""Vérifie que les en-têtes Authorization: Bearer sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "Authorization: Bearer mytoken123" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "mytoken123" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_proxy_authorization_basic() -> None:
|
||||||
|
"""Vérifie que les en-têtes Proxy-Authorization: Basic sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "Proxy-Authorization: Basic dXNlcjpwYXNz" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "dXNlcjpwYXNz" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_lowercase_authorization() -> None:
|
||||||
|
"""Vérifie que les en-têtes authorization en minuscules sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "authorization: Bearer tok" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "tok" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_text_key_value() -> None:
|
||||||
|
"""Vérifie que les affectations clé=valeur dans du texte sont masquées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "password=mysecret" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "mysecret" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_json_key_value() -> None:
|
||||||
|
"""Vérifie que les secrets dans du JSON sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = '{"api_key": "myapikey"}' # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "myapikey" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_exception() -> None:
|
||||||
|
"""Vérifie que les exceptions contenant des secrets sont masquées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
exc = Exception("Authorization: Bearer leaked_token") # pragma: allowlist secret
|
||||||
|
redacted_msg = redact_exception(exc)
|
||||||
|
assert "leaked_token" not in redacted_msg
|
||||||
|
assert "REDACTED" in redacted_msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_preserves_host_and_path() -> None:
|
||||||
|
"""Vérifie que l'hôte et le chemin d'une URL sont préservés après masquage.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "https://user:pass@example.fr/path?icalsecurise=tok" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "example.fr" in redacted
|
||||||
|
assert "/path" in redacted
|
||||||
|
assert "user" not in redacted
|
||||||
|
assert "pass" not in redacted
|
||||||
|
assert "tok" not in redacted
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure trailing newline
|
||||||
16
tests/unit/test_smoke.py
Normal file
16
tests/unit/test_smoke.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Test de fumée : vérifie que le package est importable.
|
||||||
|
|
||||||
|
Ce test garantit que ``pytest`` collecte au moins un test et retourne 0,
|
||||||
|
satisfaisant le critère d'acceptation de M1. Les tests détaillés relèvent
|
||||||
|
de M13.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pronote_sync
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_importable() -> None:
|
||||||
|
"""Vérifie que le package ``pronote_sync`` est importable sans erreur.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert pronote_sync is not None
|
||||||
Reference in New Issue
Block a user