Compare commits

..

5 Commits

Author SHA1 Message Date
4a6207f716 docs: corriger .env.example et enrichir GuidePronote (ENT obligatoire)
Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 21:40:48 +02:00
3b38253575 fix: PRONOTE_URL ignoré à cause du double préfixe env_prefix
Le champ pronote_url dans PronoteSettings avec env_prefix=PRONOTE_ produisait PRONOTE_PRONOTE_URL au lieu de PRONOTE_URL. Renomme le champ en url pour que le mécanisme standard produise PRONOTE_URL. Toutes les références mises à jour dans le code de production et les tests. Décision d'architecture : renommage préféré à un contournement par alias (mypy + dette technique).

Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 21:24:10 +02:00
bf4038814a docs: politique de versionnage et releases dans AGENTS.md
Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 21:04:22 +02:00
82b9877aad fix: PRONOTE_ENT optionnel pour les connexions pronotepy directes
PRONOTE_ENT était incorrectement traité comme obligatoire pour pronotepy. Rend ENT optionnel pour les connexions directes, conformément à la spécification. Corrige le mode pronotepy explicite et le mode auto sans iCal. 10 tests de régression ajoutés.

Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 20:54:03 +02:00
c851f67172 docs: fichier de vacances scolaires Bordeaux (zone A, 2026-2027)
Crée data/school_holidays.json avec les dates officielles de l'académie de Bordeaux (zone A) pour 2026-2027. Corrige l'erreur au démarrage quand SCHOOL_HOLIDAYS_PATH pointe vers un fichier inexistant. Documentation wiki : page GuideAgendaVacancesScolaires créée et liée dans le sidebar.

Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 20:30:47 +02:00
8 changed files with 457 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
# --- Pronote ---
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_URL=https://college.ent/pronote/eleve.html
PRONOTE_URL=https://college.ent/pronote/parent.html
PRONOTE_ACCOUNT_TYPE=parent
PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password

View File

@@ -326,3 +326,47 @@ Un changement est considéré comme **terminé** lorsque :
- Le *handoff* distingue clairement :
- Ce qui a été vérifié localement (ex. : tests unitaires, linter).
- Ce qui nécessite encore une vérification manuelle (ex. : tests d'intégration avec un serveur CalDAV réel).
---
## 13. Versionnage et releases
### Politique de versionnage
Le projet suit **Semantic Versioning** (semver.org v2.0.0). Phase actuelle : `0.x` (pré-`1.0.0`).
| Changement | Incrément |
|------------|----------|
| Défaut constaté au déploiement | Patch (`0.1.Z`) — correction rétrocompatible |
| Ajout ou cassure en phase `0.x` | Minor (`0.Y.0`) |
| Déploiement réel validé | `1.0.0` |
### Règle absolue de validation
**Aucune montée de version (tag + release) ne peut être effectuée
sans validation préalable en environnement réel.** Les tests automatisés et la revue de code
ne suffisent pas ; le correctif ou la fonctionnalité doit avoir été testé avec succès
sur le serveur de production (ou un environnement équivalent) avant de tagger.
### Procédure de release
1. **Valider en environnement réel** : le correctif ou la fonctionnalité est testé
sur le serveur de production.
2. **Mettre à jour `pyproject.toml`** : incrémenter le champ `version` à la nouvelle version.
3. **Mettre à jour `CHANGELOG.md`** : ajouter une entrée sous le format
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) avec la nouvelle version et la date.
4. **Committer** : un commit `chore: monter en version x.y.z` regroupe
les mises à jour de `pyproject.toml` et `CHANGELOG.md`.
5. **Tagger** : créer un tag annoté `vx.y.z` sur le commit de version.
6. **Pousser le tag** : `git push origin vx.y.z`.
7. **Créer la release** sur Gitea avec le changelog correspondant.
### Cohérence des versions
Les trois sources de version doivent toujours être synchronisées au moment d'un tag :
- Le tag Git (`vx.y.z`)
- `pyproject.toml` (`version = "x.y.z"`)
- `CHANGELOG.md` (`## [x.y.z] - YYYY-MM-DD`)
> **Rappel** : Ne jamais créer un tag sans avoir d'abord mis à jour
> `pyproject.toml` et `CHANGELOG.md`.

View File

@@ -37,7 +37,7 @@ class PronoteSettings(BaseSettings):
username: str | None = None
password: SecretStr | None = None
ent: str | None = None
pronote_url: str | None = None
url: str | None = None
account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"

View File

@@ -155,35 +155,34 @@ class PronoteClient:
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
Le client est créé une seule fois puis réutilisé pour les appels
suivants. Le nom d'ENT est résolu via :func:`_resolve_ent` et le
type de compte (``student`` ou ``parent``) détermine la classe de
client utilisée. L'erreur de connexion est relancée sans
journalisation, la méthode publique appelante étant responsable
de la journaliser.
suivants. Le nom d'ENT, s'il est configuré, est résolu via
:func:`_resolve_ent` ; en l'absence d'ENT, ``ent=None`` est transmis
à ``pronotepy`` pour une connexion directe. Le type de compte
(``student`` ou ``parent``) détermine la classe de client utilisée.
L'erreur de connexion est relancée sans journalisation, la méthode
publique appelante étant responsable de la journaliser.
:return: Le client ``pronotepy`` connecté.
:rtype: pronotepy.Client
:raises ValueError: Si ``pronote_url``, ``username``, ``password``
ou ``ent`` est manquant, ou si l'ENT est inconnu.
:raises ValueError: Si ``url``, ``username`` ou ``password``
est manquant, ou si l'ENT fourni est inconnu.
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
"""
if self._client is None:
pronote_url = self._settings.pronote_url
url = self._settings.url
username = self._settings.username
password = self._settings.password
ent = self._settings.ent
if pronote_url is None or username is None or password is None or ent is None:
raise ValueError(
"pronote_url, username, password et ent sont requis pour pronotepy"
)
resolver = _resolve_ent(ent)
if url is None or username is None or password is None:
raise ValueError("url, username et password sont requis pour pronotepy")
resolver = _resolve_ent(ent) if ent is not None else None
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient
if self._settings.account_type == "parent"
else pronotepy.Client
)
self._client = client_class(
pronote_url=pronote_url,
pronote_url=url,
username=username,
password=password.get_secret_value(),
ent=resolver,

View File

@@ -149,16 +149,15 @@ class PronoteFetcher:
def _is_pronotepy_configured(self) -> bool:
"""Vérifie que la source pronotepy est entièrement configurée.
:return: ``True`` si ``pronote_url``, ``username``, ``password``
et ``ent`` sont tous définis, ``False`` sinon.
:return: ``True`` si ``url``, ``username`` et ``password``
sont tous définis, ``False`` sinon.
:rtype: bool
"""
pronote = self._settings.pronote
return (
pronote.pronote_url is not None
pronote.url is not None
and pronote.username is not None
and pronote.password is not None
and pronote.ent is not None
)
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:

View File

@@ -116,4 +116,19 @@ def test_no_singleton_import() -> None:
)
def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que PRONOTE_URL mappe au champ url via le préfixe PRONOTE_.
Ce test couvre la régression où PRONOTE_URL n'était pas mappé vers le
champ du modèle à cause du double préfixe PRONOTE_.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
test_url = "https://example.index-education.net/pronote/parent.html"
monkeypatch.setenv("PRONOTE_URL", test_url)
settings = load_settings()
assert settings.pronote.url == test_url
# Ensure trailing newline

View File

@@ -53,7 +53,7 @@ def fixture_mock_settings() -> Settings:
"""
return Settings(
pronote=PronoteSettings(
pronote_url="https://pronote.example.com",
url="https://pronote.example.com",
ical_url=SecretStr("file:///fake/ical.ics"),
agenda_source="auto",
homework_source="auto",
@@ -251,7 +251,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:rtype: None
"""
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.pronote_url = None
mock_fetcher._settings.pronote.url = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -280,7 +280,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
"""
# Override settings to use ical mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "ical"
mock_fetcher._settings.pronote.pronote_url = None
mock_fetcher._settings.pronote.url = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -438,7 +438,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
target_date = date(2025, 9, 10)
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.pronote_url = None
mock_fetcher._settings.pronote.url = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -531,7 +531,7 @@ def test_no_secrets_in_error_messages(
:rtype: None
"""
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.pronote_url = None
mock_fetcher._settings.pronote.url = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -629,7 +629,7 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
"""
# Disable both sources
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.pronote_url = None
mock_fetcher._settings.pronote.url = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
@@ -1020,7 +1020,7 @@ def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher
assert fallback is None
# Without pronotepy configured
mock_fetcher._settings.pronote.pronote_url = None
mock_fetcher._settings.pronote.url = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "ical"
assert fallback is None
@@ -1080,7 +1080,7 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
"""
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.pronote_url = None
mock_fetcher._settings.pronote.url = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher._homework_sources()
@@ -1088,6 +1088,102 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value)
def test_fetch_agenda_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode auto : iCal configuré mais échoue, repli sur pronotepy sans ent.
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
et que le résultat provient de pronotepy, pas une erreur.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
start_dt = datetime(2025, 9, 1, 8, 0)
end_dt = datetime(2025, 9, 1, 9, 30)
lessons = [
Lesson(
id="l1",
start=start_dt,
end=end_dt,
subject="Maths",
teachers=("Dupont",),
rooms=("S1",),
group="2ndeA",
status=LessonStatus.NORMAL,
content=None,
)
]
# Override settings to use auto mode with ical_url configured but ent=None
mock_fetcher._settings.pronote.agenda_source = "auto"
mock_fetcher._settings.pronote.ent = None # Explicitly None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
client.get_lessons.return_value = lessons
mock_fetcher._pronote_client = client
result_lessons, result_events = mock_fetcher.fetch_agenda()
assert result_lessons == lessons
assert result_events == []
client.get_lessons.assert_called_once()
def test_fetch_homework_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
mock_fetcher: PronoteFetcher,
) -> None:
"""Test le mode auto des devoirs : iCal configuré mais échoue, repli sur pronotepy sans ent.
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
et que le résultat provient de pronotepy, pas une erreur.
:param mock_fetcher: Fetcher de test.
:return: None
:rtype: None
"""
target_date = date(2025, 9, 10)
homeworks = [
Homework(
id="hw1",
subject="Physique",
teachers=(),
assigned_on=None,
due_on=target_date,
text="TP à préparer",
html="TP à préparer",
)
]
# Override settings to use auto mode with ical_url configured but ent=None
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ent = None # Explicitly None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
):
m_fetch_ical.side_effect = OSError("iCal unreachable")
m_parse_ical.side_effect = OSError("iCal parse error")
client = MagicMock()
client.get_homeworks.return_value = homeworks
mock_fetcher._pronote_client = client
m_collect.return_value = homeworks
result = mock_fetcher.fetch_homework(target_date)
assert result == homeworks
client.get_homeworks.assert_called_once()
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
mock_fetcher: PronoteFetcher,
) -> None:
@@ -1127,7 +1223,7 @@ def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
def test_fetch_homework_auto_fallback_returns_empty_logs_warning(
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
) -> None:
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide.
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli est vide.
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
Ce test utilise le mode AUTO pour tester le comportement de repli.
@@ -1186,4 +1282,99 @@ def test_fetch_informations_logs_and_re_raises_secret(
)
def test_is_pronotepy_configured_without_ent_returns_true() -> None:
"""Test _is_pronotepy_configured() retourne True quand ent est None.
Les autres champs de la configuration pronotepy sont présents, donc la
fonction renvoie True.
Ce test valide que PRONOTE_ENT est optionnel pour pronotepy.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.sources.pronote.fallback import PronoteFetcher
# Créer des settings avec pronotepy configuré mais sans ent
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
# Should return True even without ent
assert fetcher._is_pronotepy_configured() is True
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
Ce test valide que le mode auto peut utiliser pronotepy même sans ent configuré.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.sources.pronote.fallback import PronoteFetcher
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
ical_url=None, # Pas de iCal URL
agenda_source="auto",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
primary, fallback = fetcher._agenda_sources()
assert primary == "pronotepy"
assert fallback is None
def test_homework_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
"""Test _homework_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
Ce test valide que le mode auto peut utiliser pronotepy pour les devoirs même sans ent configuré.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.sources.pronote.fallback import PronoteFetcher
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
ical_url=None, # Pas de iCal URL
homework_source="auto",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
primary, fallback = fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
# Ensure trailing newline

View File

@@ -46,7 +46,7 @@ def pronote_settings() -> PronoteSettings:
:rtype: PronoteSettings
"""
return PronoteSettings(
pronote_url="https://pronote.example.com",
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
@@ -309,7 +309,7 @@ def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) ->
"""
client = PronoteClient(empty_pronote_settings)
with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"):
with pytest.raises(ValueError, match="url, username et password sont requis pour pronotepy"):
client._connect()
@@ -365,6 +365,132 @@ def test_connect_parent_account_type(
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
def test_connect_without_ent_but_with_required_credentials(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _connect() fonctionne sans ent mais avec les autres identifiants requis.
Ce test valide que PRONOTE_ENT est optionnel pour une connexion directe Pronote.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
# Settings sans ent mais avec les autres champs requis
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None, # Explicitement None
account_type="parent",
)
mock_client = mocker.MagicMock()
mock_client_class = Mock(return_value=mock_client)
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
mocker.patch("pronotepy.Client")
client = PronoteClient(settings)
connected_client = client._connect()
# Should not raise ValueError about missing ent
assert connected_client is mock_client
# Verify ParentClient was called with ent=None
mock_client_class.assert_called_once_with(
pronote_url="https://pronote.example.com",
username="testuser",
password="testpass", # pragma: allowlist secret
ent=None, # ent should be None, not resolved
)
def test_connect_missing_required_credentials_still_raises(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _connect() lève ValueError si url, username ou password manquent.
Ce test valide que l'erreur ne mentionne plus ent comme requis.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
# Settings avec ent mais sans url
settings = PronoteSettings(
url=None,
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
)
client = PronoteClient(settings)
with pytest.raises(ValueError) as exc_info:
client._connect()
# Error should NOT mention ent as required
assert "url, username et password sont requis" in str(exc_info.value)
assert "ent" not in str(exc_info.value)
def test_connect_missing_username_raises(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _connect() lève ValueError si username manque.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
settings = PronoteSettings(
url="https://pronote.example.com",
username=None,
password=SecretStr("testpass"),
ent=None,
account_type="parent",
)
client = PronoteClient(settings)
with pytest.raises(ValueError) as exc_info:
client._connect()
assert "url, username et password sont requis" in str(exc_info.value)
def test_connect_missing_password_raises(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _connect() lève ValueError si password manque.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=None,
ent=None,
account_type="parent",
)
client = PronoteClient(settings)
with pytest.raises(ValueError) as exc_info:
client._connect()
assert "url, username et password sont requis" in str(exc_info.value)
def test_connect_student_account_type(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
@@ -379,7 +505,7 @@ def test_connect_student_account_type(
from pronote_sync.sources.pronote.client import PronoteClient
pronote_settings_student = PronoteSettings(
pronote_url="https://pronote.example.com",
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
@@ -398,6 +524,57 @@ def test_connect_student_account_type(
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
def test_connect_with_ent_resolution_still_works(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _resolve_ent est appelé et fonctionne quand ent est fourni.
Ce test valide que lorsque ent est fourni, il est toujours résolu via _resolve_ent.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.sources.pronote.client import PronoteClient
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux", # ent est fourni
account_type="parent",
)
mock_client = mocker.MagicMock()
mock_client_class = Mock(return_value=mock_client)
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
# Mock _resolve_ent to return a mock resolver
mock_resolver = Mock()
mocker.patch(
"pronote_sync.sources.pronote.client._resolve_ent",
return_value=mock_resolver,
)
client = PronoteClient(settings)
_ = client._connect()
# _resolve_ent should have been called
from pronote_sync.sources.pronote.client import _resolve_ent as resolve_ent_func
resolve_ent_func.assert_called_once_with("bordeaux") # type: ignore[attr-defined]
# ParentClient should have been called with the resolved ent
mock_client_class.assert_called_once_with(
pronote_url="https://pronote.example.com",
username="testuser",
password="testpass", # pragma: allowlist secret
ent=mock_resolver,
)
def test_get_messages_degraded_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None: