Compare commits
6 Commits
feat/qr-to
...
fix/dry-ru
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e968dd522 | |||
| 384a730e73 | |||
| f4f71461ee | |||
| 5e69c4e049 | |||
|
999ed76ba7
|
|||
| 6a8685fc9d |
@@ -140,7 +140,7 @@
|
||||
"filename": "GUIDE_DEV_PYTHON.md",
|
||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||
"is_verified": true,
|
||||
"line_number": 5064,
|
||||
"line_number": 5065,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
@@ -177,5 +177,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-09-08T10:45:46Z"
|
||||
"generated_at": "2026-09-11T05:17:54Z"
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ d'un besoin réel et testé.
|
||||
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
|
||||
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
|
||||
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour `openai-compatible` uniquement. | `False` | `bool` |
|
||||
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
|
||||
| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` |
|
||||
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
||||
|
||||
> ⚠️ **Décision d'implémentation** :
|
||||
@@ -3221,7 +3221,8 @@ class CalDAVClient:
|
||||
### 7.4 Points clés
|
||||
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
|
||||
- **Idempotence** : Deux exécutions identiques ne modifient pas le calendrier.
|
||||
- **Dry-run** : Mode obligatoire pour tester sans effet de bord.
|
||||
- **Dry-run** : Les lectures sont autorisées, sans sortie distante ni état local persistant ; le
|
||||
mode `qr_token` est refusé avant connexion car son authentification implique une rotation distante.
|
||||
- **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits.
|
||||
- **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
|
||||
- **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution.
|
||||
@@ -5760,7 +5761,7 @@ repos:
|
||||
|----------------------------------------|-----------------------------------------------------------------------------------------------------|-----------------|------------|
|
||||
| Configuration des variables d'environnement | Vérifier que toutes les variables obligatoires sont définies (voir [Section 3.1](#31-variables-denvironnement)). | ✅ Oui | |
|
||||
| Vérification des secrets | Exécuter le script de vérification de sécurité (voir [Section 13.6](#136-exemple-de-script-de-vérification-de-sécurité)). | ✅ Oui | |
|
||||
| Test en mode dry-run | Exécuter le pipeline avec `DRY_RUN=true` pour vérifier que tout fonctionne sans effet de bord. | ✅ Oui | |
|
||||
| Test en mode dry-run | Exécuter le pipeline avec `DRY_RUN=true` sans sortie distante ni état local persistant (`qr_token` exclu). | ✅ Oui | |
|
||||
| Configuration des logs | Vérifier que les logs sont configurés avec masquage des secrets (voir [Section 4.2](#42-implémentation)). | ✅ Oui | |
|
||||
| Vérification des dépendances | Exécuter `pip check` pour vérifier que toutes les dépendances sont installées. | ✅ Oui | |
|
||||
| Configuration du cron (si planifié) | Configurer une tâche cron pour exécuter le script régulièrement (ex: tous les jours à 18h). | ⚠️ Non | |
|
||||
@@ -5940,7 +5941,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
|
||||
| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
|
||||
| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
|
||||
| **UID** | Identifiant unique pour un événement iCal/CalDAV. |
|
||||
| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). |
|
||||
| **Dry-run** | Simulation avec lectures autorisées, sans sortie distante ni état local persistant ; incompatible avec `qr_token`. |
|
||||
| **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. |
|
||||
| **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |
|
||||
|
||||
@@ -6107,7 +6108,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
|
||||
| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
|
||||
| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
|
||||
| **UID** | Identifiant unique pour un événement iCal/CalDAV. |
|
||||
| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). |
|
||||
| **Dry-run** | Simulation avec lectures autorisées, sans sortie distante ni état local persistant ; incompatible avec `qr_token`. |
|
||||
| **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. |
|
||||
| **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |
|
||||
|
||||
|
||||
@@ -36,10 +36,16 @@ pronote-sync --dry-run --log-level DEBUG
|
||||
|
||||
```bash
|
||||
pronote-sync # Exécute la synchronisation
|
||||
pronote-sync --dry-run # Simulation sans écriture
|
||||
pronote-sync --dry-run # Simulation : lectures autorisées, aucune écriture persistante ni sortie distante
|
||||
pronote-sync --log-level DEBUG # Verbosité des journaux
|
||||
```
|
||||
|
||||
En `--dry-run`, les données peuvent être lues pour construire la simulation, mais aucun état local
|
||||
de source n'est enregistré : l'état RSS reste en mémoire pendant l'exécution. Les écritures CalDAV
|
||||
et l'envoi XMPP sont également désactivés. Le mode
|
||||
`PRONOTE_AUTH_MODE=qr_token` est refusé avant toute connexion, car la rotation de son token ne peut
|
||||
pas garantir un état persistant cohérent pendant une simulation.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Déploiement
|
||||
|
||||
14
TODO.md
14
TODO.md
@@ -147,7 +147,8 @@ Synchroniser différentiellement les événements Pronote vers le calendrier Cal
|
||||
|
||||
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés).
|
||||
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
|
||||
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
|
||||
- Un run dry-run autorise les lectures, mais ne produit aucune sortie distante ni écriture locale
|
||||
persistante ; `qr_token` est refusé avant connexion. Deux runs identiques donnent le même résultat.
|
||||
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
|
||||
- Les événements non marqués ne sont jamais modifiés ni supprimés.
|
||||
|
||||
@@ -223,7 +224,9 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m
|
||||
- [x] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`.
|
||||
- [x] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send.
|
||||
- [x] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`.
|
||||
- [x] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
|
||||
- [x] Implémenter le mode `dry_run` : lectures autorisées, aucune écriture CalDAV/XMPP ni écriture
|
||||
persistante locale ; l'état RSS reste en mémoire pendant l'exécution, et
|
||||
`PRONOTE_AUTH_MODE=qr_token` est refusé avant toute connexion.
|
||||
- [x] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
|
||||
- [x] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant.
|
||||
|
||||
@@ -231,7 +234,9 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m
|
||||
- [x] Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
|
||||
- [x] Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run.
|
||||
- [x] Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP.
|
||||
- [x] `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
|
||||
- [x] `dry_run=True` autorise les lectures mais n'effectue aucune écriture persistante locale ni
|
||||
sortie distante ; aucune source disponible → erreur critique explicite. L'état RSS n'est pas
|
||||
enregistré et `qr_token` est refusé avant toute connexion.
|
||||
- [x] Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison.
|
||||
- [x] Les erreurs critiques (`PipelineCriticalError`) propagées depuis une étape non-bloquante arrêtent le pipeline.
|
||||
|
||||
@@ -247,7 +252,8 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
|
||||
- [x] Gérer le code de retour et l'affichage des erreurs (redactées).
|
||||
|
||||
### Critères d'acceptation
|
||||
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.
|
||||
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans sortie distante ni état local persistant ;
|
||||
le mode `qr_token`, qui implique une rotation distante, est refusé avant connexion.
|
||||
- Le script console est installable (`[project.scripts]` dans `pyproject.toml`).
|
||||
- Les erreurs affichées ne contiennent aucun secret, y compris avec l'affichage d'un traceback complet en mode debug.
|
||||
|
||||
|
||||
@@ -42,8 +42,11 @@ bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permission
|
||||
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
|
||||
commit, utilisez `scripts/check_secrets.py --staged`.
|
||||
|
||||
Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ;
|
||||
il ne remplace pas une vérification des paramètres réellement chargés.
|
||||
Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie
|
||||
CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du
|
||||
processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande
|
||||
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
||||
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
||||
|
||||
## Installation systemd
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespa
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Simule la synchronisation sans écrire vers CalDAV ni XMPP.",
|
||||
help=(
|
||||
"Simule la synchronisation sans sortie distante ni état local persistant "
|
||||
"(incompatible avec PRONOTE_AUTH_MODE=qr_token)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
|
||||
@@ -126,6 +126,9 @@ class PipelineRunner:
|
||||
:rtype: PipelineRunner
|
||||
"""
|
||||
effective_dry_run = settings.app.dry_run if dry_run is None else dry_run
|
||||
if effective_dry_run and settings.pronote.auth_mode == "qr_token":
|
||||
raise ValueError("Le mode qr_token n'est pas compatible avec le dry-run.")
|
||||
persistence_enabled = not effective_dry_run
|
||||
theoretical_provider = get_theoretical_provider(
|
||||
settings.app.theoretical_agenda_path,
|
||||
settings.app.school_holidays_path,
|
||||
@@ -136,7 +139,9 @@ class PipelineRunner:
|
||||
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None
|
||||
)
|
||||
blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None
|
||||
blog_state = BlogRSSState() if settings.blog.enabled else None
|
||||
blog_state = (
|
||||
BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None
|
||||
)
|
||||
return cls(
|
||||
settings=settings,
|
||||
pronote_fetcher=PronoteFetcher(
|
||||
@@ -144,7 +149,9 @@ class PipelineRunner:
|
||||
PronoteClient(
|
||||
settings.pronote,
|
||||
auth_state=(
|
||||
PronoteAuthState() if settings.pronote.auth_mode == "qr_token" else None
|
||||
PronoteAuthState(persistence_enabled=persistence_enabled)
|
||||
if settings.pronote.auth_mode == "qr_token"
|
||||
else None
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -37,15 +37,24 @@ class BlogRSSState:
|
||||
|
||||
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
||||
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
|
||||
:param persistence_enabled: Si ``False``, charge l'état existant mais ne
|
||||
modifie jamais le fichier d'état. ``True`` par défaut.
|
||||
"""
|
||||
|
||||
def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
state_file: Path | str = ".blog_rss_state.json",
|
||||
persistence_enabled: bool = True,
|
||||
) -> None:
|
||||
"""Initialise le gestionnaire d'état depuis le fichier JSON.
|
||||
|
||||
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
||||
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
|
||||
:param persistence_enabled: Si ``False``, charge l'état existant mais
|
||||
désactive toutes les écritures sur disque. ``True`` par défaut.
|
||||
"""
|
||||
self._state_file = Path(state_file)
|
||||
self._persistence_enabled = persistence_enabled
|
||||
self._known_guids: set[str] = set()
|
||||
self._etag: str | None = None
|
||||
self._last_modified: str | None = None
|
||||
@@ -98,6 +107,8 @@ class BlogRSSState:
|
||||
d'erreur d'écriture, une erreur est journalisée sans être
|
||||
propagée et le fichier temporaire est supprimé.
|
||||
"""
|
||||
if not self._persistence_enabled:
|
||||
return
|
||||
payload = {
|
||||
"version": _STATE_VERSION,
|
||||
"known_guids": sorted(self._known_guids),
|
||||
|
||||
@@ -38,9 +38,15 @@ class PronoteAuthState:
|
||||
|
||||
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
||||
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
|
||||
:param persistence_enabled: Si ``False``, charge l'état existant mais ne
|
||||
modifie jamais le fichier d'état. ``True`` par défaut.
|
||||
"""
|
||||
|
||||
def __init__(self, state_file: Path | str = ".pronote_auth_state.json") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
state_file: Path | str = ".pronote_auth_state.json",
|
||||
persistence_enabled: bool = True,
|
||||
) -> None:
|
||||
"""Initialise le gestionnaire d'état d'authentification Pronote.
|
||||
|
||||
Le fichier d'état n'est pas créé à l'initialisation : il n'est écrit
|
||||
@@ -48,8 +54,14 @@ class PronoteAuthState:
|
||||
|
||||
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
||||
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
|
||||
:param persistence_enabled: Si ``False``, charge l'état existant mais
|
||||
désactive toutes les écritures ou suppressions sur disque. ``True``
|
||||
par défaut.
|
||||
"""
|
||||
self._state_file = Path(state_file)
|
||||
self._persistence_enabled = persistence_enabled
|
||||
self._in_memory_credentials: dict[str, str] | None = None
|
||||
self._in_memory_state_cleared = False
|
||||
|
||||
def load(self) -> dict[str, str] | None:
|
||||
"""Charge les credentials d'authentification depuis le fichier d'état.
|
||||
@@ -65,6 +77,10 @@ class PronoteAuthState:
|
||||
aucun état valide n'est disponible.
|
||||
:rtype: dict[str, str] | None
|
||||
"""
|
||||
if self._in_memory_state_cleared:
|
||||
return None
|
||||
if self._in_memory_credentials is not None:
|
||||
return self._in_memory_credentials.copy()
|
||||
if not self._state_file.exists():
|
||||
logger.debug(
|
||||
"Fichier d'état d'authentification Pronote %s absent, aucun token à charger.",
|
||||
@@ -126,6 +142,10 @@ class PronoteAuthState:
|
||||
:raises PronoteSyncError: Si l'écriture ou le remplacement du fichier
|
||||
échoue.
|
||||
"""
|
||||
if not self._persistence_enabled:
|
||||
self._in_memory_credentials = credentials.copy()
|
||||
self._in_memory_state_cleared = False
|
||||
return
|
||||
payload: dict[str, Any] = {
|
||||
"version": _STATE_VERSION,
|
||||
"credentials": credentials,
|
||||
@@ -182,10 +202,16 @@ class PronoteAuthState:
|
||||
"""Supprime le fichier d'état d'authentification.
|
||||
|
||||
Si le fichier n'existe pas, la méthode ne fait rien et aucune erreur
|
||||
n'est levée.
|
||||
n'est levée. Lorsque la persistance est désactivée, elle efface
|
||||
uniquement les credentials conservés en mémoire et ne modifie jamais
|
||||
le fichier d'état.
|
||||
|
||||
:raises OSError: Si la suppression du fichier existant échoue.
|
||||
"""
|
||||
self._in_memory_credentials = None
|
||||
if not self._persistence_enabled:
|
||||
self._in_memory_state_cleared = True
|
||||
return
|
||||
if not self._state_file.exists():
|
||||
return
|
||||
logger.debug(
|
||||
|
||||
@@ -11,6 +11,23 @@ from pronote_sync.errors import PipelineCriticalError, PipelineWarning
|
||||
from pronote_sync.models.pronote import PronoteData
|
||||
|
||||
|
||||
def test_help_documents_strict_dry_run_contract(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""L'aide CLI expose le contrat strict et l'incompatibilité QR/token.
|
||||
|
||||
:param capsys: Capture des sorties standard de pytest.
|
||||
:return: None
|
||||
"""
|
||||
from pronote_sync.cli.main import main
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["--help"])
|
||||
|
||||
output = " ".join(capsys.readouterr().out.split())
|
||||
assert exc_info.value.code == 0
|
||||
assert "sans sortie distante ni état local persistant" in output
|
||||
assert "PRONOTE_AUTH_MODE=qr_token" in output
|
||||
|
||||
|
||||
def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
|
||||
@@ -8,7 +8,13 @@ from typing import Any, cast
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
|
||||
from pronote_sync.config.settings import (
|
||||
AISettings,
|
||||
AppSettings,
|
||||
BlogSettings,
|
||||
PronoteSettings,
|
||||
Settings,
|
||||
)
|
||||
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
|
||||
from pronote_sync.models.blog import BlogArticle
|
||||
@@ -422,6 +428,47 @@ def test_from_settings_password_mode_passes_auth_state_none(
|
||||
assert constructed[0][1] is None
|
||||
|
||||
|
||||
def test_from_settings_rejects_qr_token_dry_run_before_constructing_pronote_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""QR-token dry-run fails before authentication or data dependencies are created."""
|
||||
import pronote_sync.pipeline.run as run_module
|
||||
|
||||
qr_pin_sentinel = "qr-pin-must-not-appear"
|
||||
constructed: list[object] = []
|
||||
|
||||
class FailingClient:
|
||||
"""Pronote client sentinel that makes unexpected construction explicit."""
|
||||
|
||||
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
|
||||
"""Record and reject any unexpected client construction.
|
||||
|
||||
:param settings: Pronote settings supplied by the composition root.
|
||||
:param auth_state: Authentication state supplied by the composition root.
|
||||
"""
|
||||
del settings, auth_state
|
||||
constructed.append(object())
|
||||
raise AssertionError("PronoteClient must not be constructed for QR-token dry-run")
|
||||
|
||||
monkeypatch.setattr(run_module, "PronoteClient", FailingClient)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
PipelineRunner.from_settings(
|
||||
Settings(
|
||||
app=AppSettings(dry_run=True),
|
||||
pronote=PronoteSettings(
|
||||
auth_mode="qr_token",
|
||||
qr_pin=SecretStr(qr_pin_sentinel),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert "qr_token" in str(exc_info.value)
|
||||
assert "dry-run" in str(exc_info.value)
|
||||
assert qr_pin_sentinel not in str(exc_info.value)
|
||||
assert constructed == []
|
||||
|
||||
|
||||
def test_from_settings_qr_token_mode_passes_auth_state_instance(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -449,6 +496,50 @@ def test_from_settings_qr_token_mode_passes_auth_state_instance(
|
||||
assert isinstance(constructed[0][1], PronoteAuthState)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dry_run", [False, True])
|
||||
def test_from_settings_configures_source_state_persistence_for_dry_run(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Composition disables source-state persistence only in dry-run mode."""
|
||||
import pronote_sync.pipeline.run as run_module
|
||||
|
||||
blog_persistence: list[bool] = []
|
||||
|
||||
class RecordingBlogState:
|
||||
"""Blog state factory recording its persistence configuration."""
|
||||
|
||||
def __init__(self, *, persistence_enabled: bool = True) -> None:
|
||||
"""Record the requested persistence setting.
|
||||
|
||||
:param persistence_enabled: Whether disk writes are enabled.
|
||||
"""
|
||||
blog_persistence.append(persistence_enabled)
|
||||
|
||||
class RecordingClient:
|
||||
"""Pronote client constructor accepting the injected auth state."""
|
||||
|
||||
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
|
||||
"""Accept the composition-root dependencies.
|
||||
|
||||
:param settings: Pronote settings.
|
||||
:param auth_state: Injected authentication state.
|
||||
"""
|
||||
del settings, auth_state
|
||||
|
||||
monkeypatch.setattr(run_module, "BlogRSSState", RecordingBlogState)
|
||||
monkeypatch.setattr(run_module, "PronoteClient", RecordingClient)
|
||||
|
||||
PipelineRunner.from_settings(
|
||||
Settings(
|
||||
app=AppSettings(dry_run=dry_run),
|
||||
blog=BlogSettings(enabled=True),
|
||||
)
|
||||
)
|
||||
|
||||
assert blog_persistence == [not dry_run]
|
||||
|
||||
|
||||
def test_runner_reuses_ical_download_and_parse_within_one_run(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
pipeline_inputs: tuple[Lesson, Homework],
|
||||
|
||||
@@ -35,6 +35,49 @@ def test_state_file_absent_empty_state(tmp_path: Path) -> None:
|
||||
assert state.get_cache_headers() == (None, None)
|
||||
|
||||
|
||||
def test_disabled_persistence_keeps_updates_in_memory_without_creating_file(tmp_path: Path) -> None:
|
||||
"""Vérifie que la persistance désactivée conserve l'état uniquement en mémoire.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:return: None
|
||||
"""
|
||||
state_file = tmp_path / "state.json"
|
||||
state = BlogRSSState(state_file, persistence_enabled=False)
|
||||
|
||||
state.add_guids(["guid-1"])
|
||||
state.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
|
||||
|
||||
assert state.get_known_guids() == frozenset({"guid-1"})
|
||||
assert state.get_cache_headers() == ("etag-123", "Wed, 01 Sep 2026 GMT")
|
||||
assert not state_file.exists()
|
||||
|
||||
|
||||
def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None:
|
||||
"""Vérifie que la persistance désactivée ne modifie pas l'état déjà stocké.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:return: None
|
||||
"""
|
||||
state_file = tmp_path / "state.json"
|
||||
original_content = json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"known_guids": ["existing-guid"],
|
||||
"etag": "old-etag",
|
||||
"last_modified": "Tue, 31 Aug 2026 GMT",
|
||||
}
|
||||
)
|
||||
state_file.write_text(original_content, encoding="utf-8")
|
||||
state = BlogRSSState(state_file, persistence_enabled=False)
|
||||
|
||||
state.add_guids(["new-guid"])
|
||||
state.update_cache_headers("new-etag", "Wed, 01 Sep 2026 GMT")
|
||||
|
||||
assert state.get_known_guids() == frozenset({"existing-guid", "new-guid"})
|
||||
assert state.get_cache_headers() == ("new-etag", "Wed, 01 Sep 2026 GMT")
|
||||
assert state_file.read_text(encoding="utf-8") == original_content
|
||||
|
||||
|
||||
def test_add_guids_persists(tmp_path: Path) -> None:
|
||||
"""Vérifie que l'ajout de GUID persiste dans le fichier JSON.
|
||||
|
||||
|
||||
@@ -35,6 +35,90 @@ def test_load_no_file_returns_none(tmp_path: Path) -> None:
|
||||
assert state.load() is None
|
||||
|
||||
|
||||
def test_disabled_persistence_keeps_credentials_in_memory_without_creating_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie que la persistance désactivée conserve les credentials en mémoire.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:return: None
|
||||
"""
|
||||
state_file = tmp_path / "auth.json"
|
||||
credentials = {
|
||||
"pronote_url": "https://example.com/pronote",
|
||||
"username": "parent-1",
|
||||
"password": "token-123", # pragma: allowlist secret
|
||||
"uuid": "uuid-456",
|
||||
}
|
||||
state = PronoteAuthState(state_file, persistence_enabled=False)
|
||||
|
||||
state.save(credentials)
|
||||
|
||||
assert state.load() == credentials
|
||||
assert not state_file.exists()
|
||||
|
||||
|
||||
def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None:
|
||||
"""Vérifie que la persistance désactivée garde les nouveaux credentials en mémoire.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:return: None
|
||||
"""
|
||||
state_file = tmp_path / "auth.json"
|
||||
original_credentials = {
|
||||
"pronote_url": "https://example.com/pronote",
|
||||
"username": "parent-1",
|
||||
"password": "old-token", # pragma: allowlist secret
|
||||
"uuid": "old-uuid",
|
||||
}
|
||||
original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode()
|
||||
new_credentials = {
|
||||
"pronote_url": "https://example.com/pronote",
|
||||
"username": "parent-1",
|
||||
"password": "new-token", # pragma: allowlist secret
|
||||
"uuid": "new-uuid",
|
||||
}
|
||||
state_file.write_bytes(original_content)
|
||||
state = PronoteAuthState(state_file, persistence_enabled=False)
|
||||
|
||||
assert state.load() == original_credentials
|
||||
|
||||
state.save(new_credentials)
|
||||
|
||||
assert state.load() == new_credentials
|
||||
assert state_file.read_bytes() == original_content
|
||||
|
||||
|
||||
def test_disabled_persistence_clear_discards_in_memory_credentials_only(tmp_path: Path) -> None:
|
||||
"""Vérifie que clear oublie l'état en mémoire sans modifier le fichier existant.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:return: None
|
||||
"""
|
||||
state_file = tmp_path / "auth.json"
|
||||
original_credentials = {
|
||||
"pronote_url": "https://example.com/pronote",
|
||||
"username": "parent-1",
|
||||
"password": "old-token", # pragma: allowlist secret
|
||||
"uuid": "old-uuid",
|
||||
}
|
||||
original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode()
|
||||
credentials = {
|
||||
"pronote_url": "https://example.com/pronote",
|
||||
"username": "parent-1",
|
||||
"password": "new-token", # pragma: allowlist secret
|
||||
"uuid": "new-uuid",
|
||||
}
|
||||
state_file.write_bytes(original_content)
|
||||
state = PronoteAuthState(state_file, persistence_enabled=False)
|
||||
|
||||
state.save(credentials)
|
||||
state.clear()
|
||||
|
||||
assert state.load() is None
|
||||
assert state_file.read_bytes() == original_content
|
||||
|
||||
|
||||
def test_save_then_load_roundtrip(tmp_path: Path) -> None:
|
||||
"""Vérifie que des credentials sauvegardés sont rechargés à l'identique.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user