From 8c6a0e3f29c1a02468810069271ebdfb1b8a64a8 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Sat, 12 Sep 2026 19:18:27 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(config):=20refuser=20les=20fen=C3=AAtre?= =?UTF-8?q?s=20de=20synchronisation=20n=C3=A9gatives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contraint SYNC_PAST_DAYS et SYNC_FUTURE_DAYS à ge=0 et documente l'effet réel de 0 jour (le jour courant reste inclus). Refs #14 --- .env.example | 2 +- GUIDE_DEV_PYTHON.md | 8 +-- pronote_sync/config/settings.py | 4 +- tests/unit/test_config.py | 123 +++++++++++++++++++++++++++++++- 4 files changed, 127 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index b268642..6e40ad2 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,7 @@ CALDAV_CALENDAR_PATH=/pronote-sync/ # Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement) CALDAV_ALLOW_INSECURE_HTTP=false -# Fenêtre de synchronisation (jours) +# Fenêtre de synchronisation (jours) — entier >= 0 ; 0 = aucune journée supplémentaire de ce côté (le jour courant reste inclus). Valeurs négatives refusées au chargement (ValidationError). SYNC_PAST_DAYS=7 SYNC_FUTURE_DAYS=30 diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 96f2116..4cd4bcf 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -299,8 +299,8 @@ d'un besoin réel et testé. | `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` | | `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `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_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` | +| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `7` | `int` | +| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `30` | `int` | | `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`| | `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`| | `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`| @@ -421,8 +421,8 @@ class CalDAVSettings(BaseSettings): password: SecretStr | None = None calendar_path: str = "/pronote-sync/" allow_insecure_http: bool = False - sync_past_days: int = 7 - sync_future_days: int = 30 + sync_past_days: int = Field(default=7, ge=0) + sync_future_days: int = Field(default=30, ge=0) class AISettings(BaseSettings): diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index 7964114..ce417c5 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -293,8 +293,8 @@ class AppSettings(BaseSettings): school_holidays_path: str | None = None theoretical_week_anchor_date: date | None = None theoretical_week_anchor_type: Literal["even", "odd"] | None = None - sync_past_days: int = 7 - sync_future_days: int = 30 + sync_past_days: int = Field(default=7, ge=0) + sync_future_days: int = Field(default=30, ge=0) class Settings(BaseSettings): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 81efb2d..1cc098d 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -10,10 +10,10 @@ from __future__ import annotations from typing import TYPE_CHECKING import pytest -from pydantic import SecretStr +from pydantic import SecretStr, ValidationError from pronote_sync.config.env import load_settings -from pronote_sync.config.settings import PronoteSettings, Settings +from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings if TYPE_CHECKING: from _pytest.monkeypatch import MonkeyPatch @@ -207,4 +207,121 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None: assert "**********" in repr(settings.pronote.qr_pin) -# Ensure trailing newline +def test_sync_past_days_negative_direct_instantiation() -> None: + """Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation. + + :return: None + """ + with pytest.raises(ValidationError): + AppSettings(sync_past_days=-1) + + +def test_sync_future_days_negative_direct_instantiation() -> None: + """Vérifie que ``sync_future_days`` négatif lève ``ValidationError`` à l'instanciation. + + :return: None + """ + with pytest.raises(ValidationError): + AppSettings(sync_future_days=-1) + + +def test_sync_past_days_zero_accepted() -> None: + """Vérifie que ``sync_past_days=0`` est accepté. + + :return: None + """ + settings = AppSettings(sync_past_days=0) + assert settings.sync_past_days == 0 + + +def test_sync_future_days_zero_accepted() -> None: + """Vérifie que ``sync_future_days=0`` est accepté. + + :return: None + """ + settings = AppSettings(sync_future_days=0) + assert settings.sync_future_days == 0 + + +def test_sync_past_days_positive_accepted() -> None: + """Vérifie que ``sync_past_days`` positif est accepté. + + :return: None + """ + settings = AppSettings(sync_past_days=7) + assert settings.sync_past_days == 7 + + +def test_sync_future_days_positive_accepted() -> None: + """Vérifie que ``sync_future_days`` positif est accepté. + + :return: None + """ + settings = AppSettings(sync_future_days=30) + assert settings.sync_future_days == 30 + + +def test_sync_past_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None: + """Vérifie que ``SYNC_PAST_DAYS=-1`` lève ``ValidationError`` via chargement env. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("SYNC_PAST_DAYS", "-1") + with pytest.raises(ValidationError): + load_settings() + + +def test_sync_future_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None: + """Vérifie que ``SYNC_FUTURE_DAYS=-1`` lève ``ValidationError`` via chargement env. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("SYNC_FUTURE_DAYS", "-1") + with pytest.raises(ValidationError): + load_settings() + + +def test_sync_past_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None: + """Vérifie que ``SYNC_PAST_DAYS=0`` est accepté via chargement env. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("SYNC_PAST_DAYS", "0") + settings = load_settings() + assert settings.app.sync_past_days == 0 + + +def test_sync_future_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None: + """Vérifie que ``SYNC_FUTURE_DAYS=0`` est accepté via chargement env. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("SYNC_FUTURE_DAYS", "0") + settings = load_settings() + assert settings.app.sync_future_days == 0 + + +def test_sync_past_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None: + """Vérifie que ``SYNC_PAST_DAYS`` positif est accepté via chargement env. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("SYNC_PAST_DAYS", "7") + settings = load_settings() + assert settings.app.sync_past_days == 7 + + +def test_sync_future_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None: + """Vérifie que ``SYNC_FUTURE_DAYS`` positif est accepté via chargement env. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("SYNC_FUTURE_DAYS", "30") + settings = load_settings() + assert settings.app.sync_future_days == 30 From 79858a0849b6805fa5467137a558990e570fee6d Mon Sep 17 00:00:00 2001 From: OpenCode Date: Sat, 12 Sep 2026 19:20:03 +0200 Subject: [PATCH 2/2] fix(pronote): ignorer les informations en mode qr_token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En mode qr_token, get_informations() retourne [] sans connexion ni verrou, ce qui évite l'échec systématique de PageActualites (erreur pronotepy 20) et le refresh redondant du token sur les instances HubEduConnect. Refs #21 Refs #20 --- .secrets.baseline | 4 +- AGENTS.md | 14 +- CHANGELOG.md | 6 +- GUIDE_DEV_PYTHON.md | 18 +- pronote_sync/sources/pronote/client.py | 16 +- tests/unit/test_pronote_client.py | 249 ++++++++++++++++++++++++- 6 files changed, 289 insertions(+), 18 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 0ed8438..e0ab0fc 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": false, - "line_number": 5125 + "line_number": 5133 } ], "tests/unit/test_caldav_gateway.py": [ @@ -185,5 +185,5 @@ } ] }, - "generated_at": "2026-09-12T12:04:07Z" + "generated_at": "2026-09-12T17:18:46Z" } diff --git a/AGENTS.md b/AGENTS.md index e994041..3b9ada2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,9 +159,12 @@ pronote-sync --dry-run - 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 atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution - (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, - informations) pour garantir la persistance du token valide. + (refresh automatique pronotepy après une `PronoteAPIError`). La persistance s'applique après + 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 + 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é. - 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'à @@ -172,6 +175,11 @@ pronote-sync --dry-run - `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`) 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. +- 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`). Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou les notifications XMPP. diff --git a/CHANGELOG.md b/CHANGELOG.md index ff336d3..4e6ddb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,17 +25,13 @@ 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_ENT` rendu optionnel pour les connexions pronotepy directes - `.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 - Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux - `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 - 694 tests passés, couverture 94.93% diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 96f2116..c172337 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -2279,6 +2279,12 @@ 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 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é à un jour cible est donc filtré explicitement sur `homework.date == target_date`. @@ -2290,11 +2296,13 @@ 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`. 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 ou informations), puis -persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une -erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas -l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus -dans les logs ni dans ce message d'erreur. +enrôlement QR initial, opération de données (agenda, devoirs, messages ; informations hors mode +`qr_token`), puis persistance des credentials actualisées. En mode `qr_token`, `get_informations()` +est ignorée (retour immédiat `[]` sans connexion ni verrou) : elle n'acquiert pas le verrou et ne +déclenche aucune persistance. Une tentative concurrente échoue immédiatement avec une erreur d'état +d'authentification expurgée ; elle ne patiente pas et ne relance pas l'authentification. Le contenu +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 filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 48670b1..6df44b6 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -488,9 +488,23 @@ class PronoteClient: Chaque entrée est mappée sur un modèle :class:`Message` de type ``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon. - :return: Liste des informations et sondages ; vide en cas d'erreur. + En mode ``qr_token``, la récupération est ignorée sans connexion ni + 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] """ + 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(): try: client = self._connect() diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index cdfa312..c9eb36a 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -621,12 +621,17 @@ def test_get_messages_degraded_on_error( def test_get_informations_degraded_on_error( - mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings + mocker: pytest_mock.MockerFixture, + pronote_settings: PronoteSettings, + caplog: pytest.LogCaptureFixture, ) -> None: """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 pronote_settings: Paramètres Pronote valides. + :param caplog: Fixture pour capturer les logs. :return: None """ mock_client = mocker.MagicMock() @@ -634,14 +639,254 @@ def test_get_informations_degraded_on_error( mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) 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 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 --- +# --- 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( mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings,