Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6575a5f60b |
+2
-2
@@ -140,7 +140,7 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": false,
|
"is_verified": false,
|
||||||
"line_number": 5133
|
"line_number": 5125
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tests/unit/test_caldav_gateway.py": [
|
"tests/unit/test_caldav_gateway.py": [
|
||||||
@@ -185,5 +185,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-12T17:18:46Z"
|
"generated_at": "2026-09-12T12:04:07Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,12 +159,9 @@ pronote-sync --dry-run
|
|||||||
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
||||||
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
||||||
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
||||||
(refresh automatique pronotepy après une `PronoteAPIError`). La persistance s'applique après
|
(refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après
|
||||||
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages)
|
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages,
|
||||||
pour garantir la persistance du token valide ; seules les opérations qui se connectent réellement
|
informations) pour garantir la persistance du token valide.
|
||||||
et récupèrent des données déclenchent la persistance. En mode `qr_token`, `get_informations()`
|
|
||||||
est ignorée (retour immédiat `[]` sans connexion ni verrou) et ne déclenche donc aucune
|
|
||||||
persistance.
|
|
||||||
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
||||||
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
||||||
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
||||||
@@ -175,11 +172,6 @@ pronote-sync --dry-run
|
|||||||
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
||||||
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
||||||
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
||||||
- En mode `qr_token`, `get_informations()` retourne **inconditionnellement** une liste vide
|
|
||||||
(`[]`) sans connexion, verrou, chargement d'état ni appel réseau, et journalise un message
|
|
||||||
INFO unique : l'endpoint `PageActualites` renvoie une erreur pronotepy 20 sur les instances
|
|
||||||
HubEduConnect testées, provoquant un refresh redondant du token. Ce contournement n'est pas
|
|
||||||
configurable ; aucun état anti-répétition n'est conservé.
|
|
||||||
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
||||||
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
||||||
les notifications XMPP.
|
les notifications XMPP.
|
||||||
|
|||||||
+5
-1
@@ -25,13 +25,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
||||||
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
||||||
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
||||||
- #20/#21 — `get_informations()` ignorée en mode `qr_token` : retourne `[]` immédiatement, sans connexion, verrou ni appel réseau, évitant l'échec systématique de l'endpoint `PageActualites` (erreur pronotepy 20 sur les instances HubEduConnect testées) et le refresh redondant du token associé.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
||||||
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run
|
||||||
|
- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations`
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- 694 tests passés, couverture 94.93%
|
- 694 tests passés, couverture 94.93%
|
||||||
|
|||||||
+5
-13
@@ -2279,12 +2279,6 @@ méthodes agenda/devoirs ne transforment jamais une erreur en liste vide : elles
|
|||||||
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
|
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
|
||||||
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
|
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
|
||||||
|
|
||||||
En mode `qr_token`, `get_informations()` est ignorée : elle retourne immédiatement `[]` sans
|
|
||||||
connexion, verrou ni appel réseau, et journalise un message INFO unique. L'endpoint
|
|
||||||
`PageActualites` renvoie en effet une erreur pronotepy 20 sur les instances HubEduConnect
|
|
||||||
testées, provoquant un refresh redondant du token. Ce comportement n'est pas configurable ;
|
|
||||||
`get_messages()` n'est pas concernée par ce contournement.
|
|
||||||
|
|
||||||
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
||||||
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
||||||
|
|
||||||
@@ -2296,13 +2290,11 @@ d'authentification et de récupération par un verrou POSIX local non bloquant,
|
|||||||
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
|
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
|
||||||
|
|
||||||
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
|
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
|
||||||
enrôlement QR initial, opération de données (agenda, devoirs, messages ; informations hors mode
|
enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis
|
||||||
`qr_token`), puis persistance des credentials actualisées. En mode `qr_token`, `get_informations()`
|
persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une
|
||||||
est ignorée (retour immédiat `[]` sans connexion ni verrou) : elle n'acquiert pas le verrou et ne
|
erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas
|
||||||
déclenche aucune persistance. Une tentative concurrente échoue immédiatement avec une erreur d'état
|
l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus
|
||||||
d'authentification expurgée ; elle ne patiente pas et ne relance pas l'authentification. Le contenu
|
dans les logs ni dans ce message d'erreur.
|
||||||
du token, le PIN et les autres credentials ne sont jamais inclus dans les logs ni dans ce message
|
|
||||||
d'erreur.
|
|
||||||
|
|
||||||
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
|
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
|
||||||
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
|
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
|
||||||
|
|||||||
@@ -488,23 +488,9 @@ class PronoteClient:
|
|||||||
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
||||||
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
||||||
|
|
||||||
En mode ``qr_token``, la récupération est ignorée sans connexion ni
|
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||||
appel réseau : l'endpoint ``PageActualites`` renvoie une erreur
|
|
||||||
pronotepy 20 sur les instances HubEduConnect testées, provoquant un
|
|
||||||
refresh redondant du token. La méthode retourne alors immédiatement
|
|
||||||
une liste vide et journalise un message INFO unique ; ce comportement
|
|
||||||
n'est pas configurable.
|
|
||||||
|
|
||||||
:return: Liste des informations et sondages ; vide en cas d'erreur ou
|
|
||||||
en mode ``qr_token``.
|
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
if self._settings.auth_mode == "qr_token":
|
|
||||||
logger.info(
|
|
||||||
"Récupération des informations Pronote ignorée : endpoint "
|
|
||||||
"PageActualites indisponible en mode d'authentification qr_token."
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
with self._qr_token_operation_lock():
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, time
|
from datetime import datetime, timedelta
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
||||||
@@ -33,7 +33,15 @@ MANAGED_VALUE = "v1"
|
|||||||
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
||||||
|
|
||||||
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
||||||
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION")
|
_SIGNATURE_KEYS: tuple[str, ...] = (
|
||||||
|
"UID",
|
||||||
|
"SUMMARY",
|
||||||
|
"DTSTART",
|
||||||
|
"DTEND",
|
||||||
|
"STATUS",
|
||||||
|
"TRANSP",
|
||||||
|
"DESCRIPTION",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def lesson_to_vevent(lesson: Lesson) -> Event:
|
def lesson_to_vevent(lesson: Lesson) -> Event:
|
||||||
@@ -88,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
|
|||||||
def homework_to_vevent(homework: Homework) -> Event:
|
def homework_to_vevent(homework: Homework) -> Event:
|
||||||
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
||||||
|
|
||||||
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la
|
Le devoir est représenté comme un événement toute la journée à la date
|
||||||
journée d'échéance, entre 08:00 et 18:00.
|
d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
|
||||||
|
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
|
||||||
|
|
||||||
:param homework: Devoir Pronote à sérialiser.
|
:param homework: Devoir Pronote à sérialiser.
|
||||||
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
||||||
@@ -98,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event:
|
|||||||
event = Event()
|
event = Event()
|
||||||
event.add("uid", f"homework-{homework.id}")
|
event.add("uid", f"homework-{homework.id}")
|
||||||
event.add("summary", f"Devoir: {homework.subject}")
|
event.add("summary", f"Devoir: {homework.subject}")
|
||||||
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0))))
|
event.add("dtstart", vDate(homework.due_on))
|
||||||
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0))))
|
event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
|
||||||
event.add("description", homework.text)
|
event.add("description", homework.text)
|
||||||
event.add("status", "NEEDS-ACTION")
|
event.add("transp", "TRANSPARENT")
|
||||||
event.add("categories", ["Pronote", "Devoir"])
|
event.add("categories", ["Pronote", "Devoir"])
|
||||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||||
return event
|
return event
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -122,10 +122,16 @@ class FakeCalendar:
|
|||||||
event_start = raw_start.dt
|
event_start = raw_start.dt
|
||||||
event_end = raw_end.dt if raw_end is not None else event_start
|
event_end = raw_end.dt if raw_end is not None else event_start
|
||||||
overlaps = True
|
overlaps = True
|
||||||
if start is not None:
|
if isinstance(event_start, datetime):
|
||||||
overlaps = overlaps and event_end > start
|
if start is not None:
|
||||||
if end is not None:
|
overlaps = overlaps and event_end > start
|
||||||
overlaps = overlaps and event_start < end
|
if end is not None:
|
||||||
|
overlaps = overlaps and event_start < end
|
||||||
|
else:
|
||||||
|
if start is not None:
|
||||||
|
overlaps = overlaps and event_end > start.date()
|
||||||
|
if end is not None:
|
||||||
|
overlaps = overlaps and event_start < end.date()
|
||||||
if overlaps:
|
if overlaps:
|
||||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||||
break
|
break
|
||||||
@@ -686,6 +692,80 @@ class TestCalDAVSynchronize:
|
|||||||
assert result.added == 1
|
assert result.added == 1
|
||||||
assert len(fake_caldav_server.get_events()) == 1
|
assert len(fake_caldav_server.get_events()) == 1
|
||||||
|
|
||||||
|
def test_legacy_homework_vevent_is_migrated_idempotently_and_removed(
|
||||||
|
self,
|
||||||
|
fake_caldav_server: FakeCalDAVServer,
|
||||||
|
full_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
"""Migre un ancien devoir puis vérifie l'idempotence et la suppression."""
|
||||||
|
fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text(
|
||||||
|
uid="homework-hw-legacy",
|
||||||
|
summary="Devoir: Histoire",
|
||||||
|
start=datetime(2026, 1, 20, 8, 0),
|
||||||
|
end=datetime(2026, 1, 20, 18, 0),
|
||||||
|
status="NEEDS-ACTION",
|
||||||
|
managed=True,
|
||||||
|
)
|
||||||
|
homework = Homework(
|
||||||
|
id="hw-legacy",
|
||||||
|
subject="Histoire",
|
||||||
|
assigned_on=None,
|
||||||
|
due_on=date(2026, 1, 20),
|
||||||
|
text="Lire le chapitre 5",
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_data(homeworks: list[Homework]) -> PronoteData:
|
||||||
|
"""Construit les données de synchronisation du scénario."""
|
||||||
|
return PronoteData(
|
||||||
|
lessons=[],
|
||||||
|
homeworks=homeworks,
|
||||||
|
school_events=[],
|
||||||
|
messages=[],
|
||||||
|
target_date=date(2026, 1, 20),
|
||||||
|
generated_at=datetime(2026, 1, 14, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = synchronize(
|
||||||
|
pronote_data=make_data([homework]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert first.status == CalDAVSyncStatus.SUCCESS
|
||||||
|
assert first.added == 0
|
||||||
|
assert first.updated == 1
|
||||||
|
assert first.removed == 0
|
||||||
|
|
||||||
|
migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"])
|
||||||
|
event = migrated.walk("VEVENT")[0]
|
||||||
|
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
||||||
|
assert event.get("DTEND").dt == date(2026, 1, 21)
|
||||||
|
assert event.get("STATUS") is None
|
||||||
|
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
||||||
|
|
||||||
|
second = synchronize(
|
||||||
|
pronote_data=make_data([homework]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert second.status == CalDAVSyncStatus.SKIPPED
|
||||||
|
assert second.added == 0
|
||||||
|
assert second.updated == 0
|
||||||
|
assert second.removed == 0
|
||||||
|
|
||||||
|
removed = synchronize(
|
||||||
|
pronote_data=make_data([]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert removed.status == CalDAVSyncStatus.SUCCESS
|
||||||
|
assert removed.added == 0
|
||||||
|
assert removed.updated == 0
|
||||||
|
assert removed.removed == 1
|
||||||
|
assert fake_caldav_server.get_events() == {}
|
||||||
|
|
||||||
def test_school_event_sync(
|
def test_school_event_sync(
|
||||||
self,
|
self,
|
||||||
fake_caldav_server: FakeCalDAVServer,
|
fake_caldav_server: FakeCalDAVServer,
|
||||||
|
|||||||
@@ -621,17 +621,12 @@ def test_get_messages_degraded_on_error(
|
|||||||
|
|
||||||
|
|
||||||
def test_get_informations_degraded_on_error(
|
def test_get_informations_degraded_on_error(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
pronote_settings: PronoteSettings,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
||||||
|
|
||||||
Assert que le chemin d'erreur retourne toujours [] avec un log ERROR.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param pronote_settings: Paramètres Pronote valides.
|
:param pronote_settings: Paramètres Pronote valides.
|
||||||
:param caplog: Fixture pour capturer les logs.
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
mock_client = mocker.MagicMock()
|
mock_client = mocker.MagicMock()
|
||||||
@@ -639,254 +634,14 @@ def test_get_informations_degraded_on_error(
|
|||||||
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||||
|
|
||||||
client = PronoteClient(pronote_settings)
|
client = PronoteClient(pronote_settings)
|
||||||
|
messages = client.get_informations()
|
||||||
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
assert messages == []
|
||||||
# Assert ERROR log is present
|
|
||||||
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
|
||||||
assert len(error_records) >= 1
|
|
||||||
assert any(
|
|
||||||
"Échec de la récupération des informations Pronote" in r.message for r in error_records
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- QR code / token authentication tests ---
|
# --- QR code / token authentication tests ---
|
||||||
|
|
||||||
|
|
||||||
# --- get_informations qr_token mode guard tests ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_skips_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que get_informations retourne [] immédiatement en mode qr_token.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
assert client._client is None
|
|
||||||
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
assert client._client is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_no_connect_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que _connect n'est jamais appelé en mode qr_token pour get_informations.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
connect_spy = mocker.spy(PronoteClient, "_connect")
|
|
||||||
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
connect_spy.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_no_information_and_surveys_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que information_and_surveys n'est jamais appelé en mode qr_token.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
mock_client = mocker.MagicMock()
|
|
||||||
mock_client.information_and_surveys = mocker.MagicMock()
|
|
||||||
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
||||||
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
mock_client.information_and_surveys.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_qr_token_no_side_effects(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Prouve le contrat complet sans effet de bordure du chemin de saut qr_token.
|
|
||||||
|
|
||||||
Avec PronoteAuthState et _qr_token_operation_lock et _persist_credentials
|
|
||||||
mockés, assert que sur le saut : le gestionnaire de contexte de verrou n'est
|
|
||||||
PAS entré, auth-state load() n'est PAS appelé, _persist_credentials() n'est
|
|
||||||
PAS appelé, et l'export des credentials n'est PAS invoqué. Assert aussi que
|
|
||||||
self._client est inchangé.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:param caplog: Fixture pour capturer les logs.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
||||||
auth_state.load = mocker.MagicMock()
|
|
||||||
auth_state.lock = mocker.MagicMock()
|
|
||||||
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
|
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
|
||||||
sentinel = MagicMock()
|
|
||||||
client._client = sentinel
|
|
||||||
persist_spy = mocker.spy(client, "_persist_credentials")
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
# Assert no side effects
|
|
||||||
assert messages == []
|
|
||||||
assert client._client is sentinel
|
|
||||||
assert isinstance(sentinel, MagicMock)
|
|
||||||
sentinel.export_credentials.assert_not_called()
|
|
||||||
persist_spy.assert_not_called()
|
|
||||||
auth_state.load.assert_not_called()
|
|
||||||
auth_state.lock.assert_not_called()
|
|
||||||
|
|
||||||
# Assert exactly one INFO log record with the exact message
|
|
||||||
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
|
||||||
assert len(info_records) == 1
|
|
||||||
assert (
|
|
||||||
info_records[0].message
|
|
||||||
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
|
||||||
"indisponible en mode d'authentification qr_token."
|
|
||||||
)
|
|
||||||
# Assert no secret sentinel appears in any log record
|
|
||||||
for record in caplog.records:
|
|
||||||
assert "testpass" not in record.message
|
|
||||||
assert "testuser" not in record.message
|
|
||||||
assert "pronote.example.com" not in record.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_logs_info_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que get_informations log un message INFO exact en mode qr_token.
|
|
||||||
|
|
||||||
Assert exactement un enregistrement logging.INFO avec le message exact
|
|
||||||
(inspection de caplog.records, pas seulement caplog.text), et qu'aucune
|
|
||||||
sentinelle de secret n'apparaît.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:param caplog: Fixture pour capturer les logs.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
# Assert exactly one INFO record with the exact message
|
|
||||||
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
|
||||||
assert len(info_records) == 1
|
|
||||||
assert (
|
|
||||||
info_records[0].message
|
|
||||||
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
|
||||||
"indisponible en mode d'authentification qr_token."
|
|
||||||
)
|
|
||||||
# Assert no secret sentinel appears in any record
|
|
||||||
for record in caplog.records:
|
|
||||||
assert "testpass" not in record.message
|
|
||||||
assert "testuser" not in record.message
|
|
||||||
assert "pronote.example.com" not in record.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_unchanged_in_password_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
pronote_settings: PronoteSettings,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que get_informations en mode password reste inchangé (régression).
|
|
||||||
|
|
||||||
Assert que _connect() A ÉTÉ appelé et information_and_surveys() A ÉTÉ appelé
|
|
||||||
(en cas de succès), en conservant les assertions de mappage existantes.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:param pronote_settings: Paramètres Pronote valides en mode password.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
mock_client = mocker.MagicMock()
|
|
||||||
mock_info = mocker.MagicMock()
|
|
||||||
mock_info.id = "info-456"
|
|
||||||
mock_info.title = "Important Info"
|
|
||||||
mock_info.content.return_value = "Important content"
|
|
||||||
mock_info.author = "Admin"
|
|
||||||
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
|
|
||||||
mock_info.read = False
|
|
||||||
mock_info.survey = True
|
|
||||||
mock_client.information_and_surveys.return_value = [mock_info]
|
|
||||||
|
|
||||||
# Patch _connect to return mock_client and track calls
|
|
||||||
connect_patch = mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
||||||
|
|
||||||
client = PronoteClient(pronote_settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
# Assert _connect() WAS called and information_and_surveys() WAS called
|
|
||||||
connect_patch.assert_called_once()
|
|
||||||
mock_client.information_and_surveys.assert_called_once()
|
|
||||||
|
|
||||||
# Retain existing mapping assertions
|
|
||||||
assert isinstance(messages, list)
|
|
||||||
assert len(messages) == 1
|
|
||||||
message = messages[0]
|
|
||||||
assert isinstance(message, Message)
|
|
||||||
assert message.id == "info-456"
|
|
||||||
assert message.type == MessageType.SURVEY
|
|
||||||
assert message.title == "Important Info"
|
|
||||||
assert message.content == "Important content"
|
|
||||||
assert message.author == "Admin"
|
|
||||||
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
|
|
||||||
assert message.read is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_connect_password_mode_unchanged(
|
def test_connect_password_mode_unchanged(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
pronote_settings: PronoteSettings,
|
pronote_settings: PronoteSettings,
|
||||||
|
|||||||
@@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None:
|
|||||||
assert str(event.get("UID")) == "homework-HW-5678"
|
assert str(event.get("UID")) == "homework-HW-5678"
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_status() -> None:
|
def test_homework_to_vevent_is_transparent_without_task_status() -> None:
|
||||||
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
|
"""Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework()
|
homework = _make_homework()
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert str(event.get("STATUS")) == "NEEDS-ACTION"
|
assert event.get("STATUS") is None
|
||||||
|
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_categories() -> None:
|
def test_homework_to_vevent_categories() -> None:
|
||||||
@@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_dtstart_dtend() -> None:
|
def test_homework_to_vevent_dtstart_dtend() -> None:
|
||||||
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
|
"""Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework(due_on=date(2026, 1, 20))
|
homework = _make_homework(due_on=date(2026, 1, 20))
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
|
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
||||||
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
|
assert event.get("DTEND").dt == date(2026, 1, 21)
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_summary() -> None:
|
def test_homework_to_vevent_summary() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user