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>
This commit was merged in pull request #2.
This commit is contained in:
@@ -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(
|
||||
pronote_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(
|
||||
pronote_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(
|
||||
pronote_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
|
||||
|
||||
@@ -309,7 +309,9 @@ 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="pronote_url, username et password sont requis pour pronotepy"
|
||||
):
|
||||
client._connect()
|
||||
|
||||
|
||||
@@ -365,6 +367,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(
|
||||
pronote_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 pronote_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 pronote_url
|
||||
settings = PronoteSettings(
|
||||
pronote_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 "pronote_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(
|
||||
pronote_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 "pronote_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(
|
||||
pronote_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 "pronote_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:
|
||||
@@ -398,6 +526,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(
|
||||
pronote_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:
|
||||
|
||||
Reference in New Issue
Block a user