"""Integration tests for the M11 pipeline orchestration.""" from __future__ import annotations from datetime import date, datetime from typing import Any, cast import pytest from pydantic import SecretStr from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent from pronote_sync.models.blog import BlogArticle from pronote_sync.models.diff import AgendaDiff from pronote_sync.models.homework import Homework from pronote_sync.models.message import Message from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus from pronote_sync.models.xmpp import XmppMessage from pronote_sync.pipeline.run import PipelineRunner from pronote_sync.sources.blog.result import BlogRSSFetchResult from pronote_sync.sources.blog.rss import BlogRSSClient from pronote_sync.sources.blog.state import BlogRSSState from pronote_sync.sources.pronote.auth_state import PronoteAuthState from pronote_sync.sources.pronote.fallback import PronoteFetcher from pronote_sync.sync.diff import AgendaComparator class StubFetcher: """Pronote fetcher returning deterministic data and recording its calls.""" def __init__(self, calls: list[str], lesson: Lesson, homework: Homework) -> None: """Store the dependencies required by the stub. :param calls: Shared call-order recorder. :param lesson: Lesson returned by the agenda method. :param homework: Homework returned by the homework method. """ self._calls = calls self._lesson = lesson self._homework = homework def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]: """Return one lesson for the target date. :return: The lesson and no school event. :rtype: tuple[list[Lesson], list[SchoolEvent]] """ self._calls.append("fetch") return [self._lesson], [] def fetch_homework(self, target_date: date) -> list[Homework]: """Return the configured homework. :param target_date: Requested due date. :return: The configured homework. :rtype: list[Homework] """ self._calls.append("fetch_homework") assert target_date == self._lesson.start.date() return [self._homework] def fetch_messages(self) -> list[Message]: """Return no Pronote messages. :return: An empty list. :rtype: list[Message] """ self._calls.append("fetch_messages") return [] def fetch_informations(self) -> list[Message]: """Return no Pronote information messages. :return: An empty list. :rtype: list[Message] """ self._calls.append("fetch_informations") return [] class StubChannel: """Notification channel recording its send attempts.""" def __init__(self, calls: list[str]) -> None: """Store the shared call-order recorder. :param calls: Shared call-order recorder. """ self._calls = calls self.messages: list[Any] = [] def send(self, message: Any) -> bool: """Record an outgoing message. :param message: Message produced by the runner. :return: Always ``True``. :rtype: bool """ self.messages.append(message) self._calls.append("send") return True class StubComparator: """Agenda comparator recording comparison calls.""" def __init__(self, calls: list[str]) -> None: """Store the shared call-order recorder. :param calls: Shared call-order recorder. """ self._calls = calls def compare(self, lessons: list[Lesson], target_date: date) -> AgendaDiff: """Return an empty diff after recording the comparison. :param lessons: Normalized lessons. :param target_date: Target digest date. :return: Empty agenda diff. :rtype: AgendaDiff """ self._calls.append("compare") return AgendaDiff(target_date=target_date) @pytest.fixture def pipeline_inputs() -> tuple[Lesson, Homework]: """Return deterministic Pronote data targeting 2026-09-09. :return: A lesson and homework pair. :rtype: tuple[Lesson, Homework] """ lesson = Lesson( id="lesson-1", start=datetime(2026, 9, 9, 8, 0), end=datetime(2026, 9, 9, 9, 0), subject="Maths", group=None, status=LessonStatus.NORMAL, content=None, ) homework = Homework( id="homework-1", subject="Maths", assigned_on=None, due_on=date(2026, 9, 9), text="Exercise 1", ) return lesson, homework def successful_sync_result() -> CalDAVSyncResult: """Construit un résultat CalDAV de succès compatible avec mypy. :return: Résultat de synchronisation sans changement. :rtype: CalDAVSyncResult """ return CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0) def _record_ical_fetch(calls: list[str], url: str) -> str: """Enregistre un téléchargement iCal simulé. :param calls: Liste partagée des téléchargements. :param url: URL iCal reçue par la source. :return: Contenu iCal minimal simulé. :rtype: str """ calls.append(url) return "BEGIN:VCALENDAR" def test_runner_executes_steps_in_contractual_order( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """The runner executes fetch, blog, comparison, CalDAV, synthesis, then XMPP.""" lesson, homework = pipeline_inputs calls: list[str] = [] def synchronize(data: Any, settings: Settings) -> CalDAVSyncResult: """Record the CalDAV step. :param data: Normalized Pronote data. :param settings: Effective settings. :return: Successful result. :rtype: CalDAVSyncResult """ del data, settings calls.append("caldav_sync") return successful_sync_result() class RaisingSynthesisProvider: """Synthesis provider used solely to record the optional stage.""" def generate(self, input_data: Any) -> None: """Record synthesis and return no result. :param input_data: Synthesis input. :return: No synthesis. :rtype: None """ del input_data calls.append("synthesis") return None runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=synchronize, agenda_comparator=cast("AgendaComparator", StubComparator(calls)), synthesis_provider=RaisingSynthesisProvider(), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert errors == [] assert calls == [ "fetch", "fetch_homework", "fetch_messages", "fetch_informations", "compare", "caldav_sync", "synthesis", "send", ] def test_runner_continues_to_xmpp_when_synthesis_fails( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """A non-critical synthesis exception produces a warning and still sends XMPP.""" lesson, homework = pipeline_inputs calls: list[str] = [] class FailingSynthesisProvider: """Synthesis provider raising a non-critical error.""" def generate(self, input_data: Any) -> None: """Raise a deterministic optional-stage failure. :param input_data: Synthesis input. :raises RuntimeError: Always. """ del input_data raise RuntimeError("synthetic AI failure") runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), synthesis_provider=FailingSynthesisProvider(), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert calls[-1] == "send" assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "synthesis" def test_runner_dry_run_skips_caldav_and_xmpp_writes( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """Dry-run bypasses both mutable destination boundaries.""" lesson, homework = pipeline_inputs calls: list[str] = [] def dry_run_caldav(data: Any, settings: Settings) -> CalDAVSyncResult: """Verify that the CalDAV boundary receives dry-run settings. :param data: Normalized Pronote data. :param settings: Effective settings. :return: A skipped result. :rtype: CalDAVSyncResult """ del data calls.append("caldav_sync") assert settings.app.dry_run is True return CalDAVSyncResult(status=CalDAVSyncStatus.SKIPPED, added=0, updated=0, removed=0) runner = PipelineRunner( settings=Settings(app=AppSettings(dry_run=True)), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=dry_run_caldav, channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert errors == [] assert "caldav_sync" in calls assert "send" not in calls def test_runner_without_theoretical_agenda_produces_an_empty_diff( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """A disabled theoretical agenda reaches XMPP with no agenda changes.""" lesson, homework = pipeline_inputs calls: list[str] = [] channel = StubChannel(calls) runner = PipelineRunner( settings=Settings(app=AppSettings(theoretical_agenda_path=None)), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), channel=channel, now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert errors == [] assert len(channel.messages) == 1 assert channel.messages[0].changes == () def test_runner_reports_critical_error_when_no_pronote_source_is_available() -> None: """No configured Pronote source returns an explicit critical pipeline error.""" settings = Settings(pronote=PronoteSettings()) runner = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, object()), # type: ignore[arg-type] ) data, errors = runner.run() assert data is None assert len(errors) == 1 assert isinstance(errors[0], PipelineCriticalError) assert errors[0].step is None assert "ni la source iCal ni pronotepy" in errors[0].message def test_from_settings_without_theoretical_agenda_does_not_instantiate_comparator( monkeypatch: pytest.MonkeyPatch, ) -> None: """Missing theoretical configuration keeps comparison disabled without constructing it.""" import pronote_sync.pipeline.run as run_module def forbidden_comparator(provider: object) -> None: """Fail if comparison is constructed without configuration. :param provider: The provider unexpectedly supplied. :raises AssertionError: Always. """ del provider raise AssertionError("AgendaComparator must not be instantiated") monkeypatch.setattr(run_module, "AgendaComparator", forbidden_comparator) runner = PipelineRunner.from_settings(Settings(app=AppSettings(theoretical_agenda_path=None))) assert runner._agenda_comparator is None def test_from_settings_with_theoretical_agenda_instantiates_comparator( monkeypatch: pytest.MonkeyPatch, ) -> None: """Configured theoretical agenda constructs the comparator with its provider.""" import pronote_sync.pipeline.run as run_module provider = object() constructed_with: list[object] = [] class RecordingComparator: """Comparator constructor recording the supplied provider.""" def __init__(self, received_provider: object) -> None: """Record the provider used by the composition root. :param received_provider: The constructed theoretical provider. """ constructed_with.append(received_provider) monkeypatch.setattr(run_module, "get_theoretical_provider", lambda *args: provider) monkeypatch.setattr(run_module, "AgendaComparator", RecordingComparator) runner = PipelineRunner.from_settings( Settings(app=AppSettings(theoretical_agenda_path="/agenda.json")) ) assert constructed_with == [provider] assert isinstance(runner._agenda_comparator, RecordingComparator) def test_from_settings_password_mode_passes_auth_state_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """Password mode (default) constructs PronoteClient with auth_state=None.""" import pronote_sync.pipeline.run as run_module constructed: list[tuple[object, object]] = [] class RecordingClient: """PronoteClient constructor recording the supplied auth_state.""" def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None: """Record the constructor arguments used by the composition root. :param settings: Pronote settings supplied by the composition root. :param auth_state: Auth state handler supplied by the composition root. """ constructed.append((settings, auth_state)) monkeypatch.setattr(run_module, "PronoteClient", RecordingClient) PipelineRunner.from_settings(Settings()) assert len(constructed) == 1 assert constructed[0][1] is None def test_from_settings_qr_token_mode_passes_auth_state_instance( monkeypatch: pytest.MonkeyPatch, ) -> None: """QR-token mode constructs PronoteClient with a PronoteAuthState instance.""" import pronote_sync.pipeline.run as run_module constructed: list[tuple[object, object]] = [] class RecordingClient: """PronoteClient constructor recording the supplied auth_state.""" def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None: """Record the constructor arguments used by the composition root. :param settings: Pronote settings supplied by the composition root. :param auth_state: Auth state handler supplied by the composition root. """ constructed.append((settings, auth_state)) monkeypatch.setattr(run_module, "PronoteClient", RecordingClient) PipelineRunner.from_settings(Settings(pronote=PronoteSettings(auth_mode="qr_token"))) assert len(constructed) == 1 assert isinstance(constructed[0][1], PronoteAuthState) def test_runner_reuses_ical_download_and_parse_within_one_run( monkeypatch: pytest.MonkeyPatch, pipeline_inputs: tuple[Lesson, Homework], ) -> None: """An iCal agenda/homework selection downloads and parses once per runner run.""" import pronote_sync.sources.pronote.fallback as fallback_module lesson, _ = pipeline_inputs settings = Settings( pronote=PronoteSettings( ical_url=SecretStr("https://pronote.example.test/calendar.ics"), agenda_source="ical", homework_source="ical", ) ) fetch_calls: list[str] = [] monkeypatch.setattr( fallback_module, "fetch_ical", lambda url: _record_ical_fetch(fetch_calls, url), ) monkeypatch.setattr(fallback_module, "parse_ical", lambda raw: ([lesson], [], [])) class NoMessageClient: """Minimal pronotepy client for non-critical M11 fetches.""" def get_messages(self) -> list[Message]: """Return no messages. :return: An empty list. :rtype: list[Message] """ return [] def get_informations(self) -> list[Message]: """Return no information messages. :return: An empty list. :rtype: list[Message] """ return [] def get_lessons(self, start: date, end: date) -> list[Lesson]: """Ne retourne aucun cours de repli. :param start: Début de la fenêtre. :param end: Fin de la fenêtre. :return: Liste vide. :rtype: list[Lesson] """ del start, end return [] def get_homeworks(self, start: date, end: date) -> list[Homework]: """Ne retourne aucun devoir de repli. :param start: Début de la fenêtre. :param end: Fin de la fenêtre. :return: Liste vide. :rtype: list[Homework] """ del start, end return [] runner = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, NoMessageClient()), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert errors == [] assert fetch_calls == ["https://pronote.example.test/calendar.ics"] class FailingBlogClient: """Blog client raising an exception to test error handling.""" def fetch_and_parse( self, *, known_guids: frozenset[str] | None = None, etag: str | None = None, last_modified: str | None = None, ) -> Any: """Raise an exception to simulate blog fetch failure. :param known_guids: Known GUIDs (unused). :param etag: ETag header (unused). :param last_modified: Last-Modified header (unused). :return: Never returns. :raises RuntimeError: Always. """ del known_guids, etag, last_modified raise RuntimeError("Blog fetch failed: network error") class FailingComparator: """Comparator raising an exception to test error handling.""" def compare(self, lessons: list[Lesson], target_date: date) -> AgendaDiff: """Raise an exception to simulate comparison failure. :param lessons: Lessons to compare (unused). :param target_date: Target date (unused). :return: Never returns. :raises RuntimeError: Always. """ del lessons, target_date raise RuntimeError("Comparison failed: invalid data") class FailingCaldavSynchronizer: """CalDAV synchronizer raising an exception to test error handling.""" def __call__(self, data: Any, settings: Settings) -> CalDAVSyncResult: """Raise an exception to simulate CalDAV sync failure. :param data: Data to sync (unused). :param settings: Settings (unused). :return: Never returns. :raises RuntimeError: Always. """ del data, settings raise RuntimeError("CalDAV sync failed: connection error") class FailingChannel: """Channel that returns False to test error handling.""" def __init__(self) -> None: """Initialize the failing channel.""" self.messages: list[Any] = [] def send(self, message: Any) -> bool: """Return False to simulate refused send. :param message: Message to send (unused). :return: Always False. :rtype: bool """ self.messages.append(message) return False class ExceptionalChannel: """Channel that raises an exception to test error handling.""" def __init__(self) -> None: """Initialize the exceptional channel.""" self.messages: list[Any] = [] def send(self, message: Any) -> bool: """Raise an exception to simulate send failure. :param message: Message to send (unused). :return: Never returns. :raises RuntimeError: Always. """ self.messages.append(message) raise RuntimeError("XMPP send failed: connection closed") class CriticalChannel: """Channel that raises PipelineCriticalError to test propagation.""" def send(self, message: Any) -> bool: """Raise PipelineCriticalError to test propagation. :param message: Message to send (unused). :return: Never returns. :raises PipelineCriticalError: Always. """ del message raise PipelineCriticalError("Critical send failure", step="send") class EmptyFetcher: """Pronote fetcher returning empty agenda and homework.""" def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]: """Return empty agenda. :return: Empty lessons and events. :rtype: tuple[list[Lesson], list[SchoolEvent]] """ return [], [] def fetch_homework(self, target_date: date) -> list[Homework]: """Return empty homework. :param target_date: Target date (unused). :return: Empty homework list. :rtype: list[Homework] """ del target_date return [] def fetch_messages(self) -> list[Message]: """Return no messages. :return: Empty list. :rtype: list[Message] """ return [] def fetch_informations(self) -> list[Message]: """Return no information messages. :return: Empty list. :rtype: list[Message] """ return [] @pytest.fixture def caldav_failed_result() -> CalDAVSyncResult: """Fixture providing a failed CalDAV sync result. :return: Failed sync result with errors. :rtype: CalDAVSyncResult """ return CalDAVSyncResult( status=CalDAVSyncStatus.FAILED, added=0, updated=0, removed=0, errors=["Error 1: invalid credentials", "Error 2: server unavailable"], ) def test_runner_blog_failure_produces_pipeline_warning( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """Blog failure produces PipelineWarning and continues with empty blog_articles.""" lesson, homework = pipeline_inputs calls: list[str] = [] runner = PipelineRunner( settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), blog_client=cast("BlogRSSClient | None", FailingBlogClient()), blog_state=BlogRSSState(), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "fetch_blog" assert "Blog fetch failed" in errors[0].message # Verify pipeline continued to next steps assert len(calls) >= 5 # fetch, fetch_homework, fetch_messages, fetch_informations, compare def test_runner_compare_failure_produces_pipeline_warning( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """Compare failure produces PipelineWarning and continues with empty AgendaDiff.""" lesson, homework = pipeline_inputs calls: list[str] = [] runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), agenda_comparator=cast("AgendaComparator | None", FailingComparator()), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "compare" assert "Comparison failed" in errors[0].message # Pipeline continues to next steps even if compare fails def test_runner_caldav_sync_exception_produces_pipeline_warning( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """CalDAV synchronizer raises exception → PipelineWarning with step='caldav_sync'.""" lesson, homework = pipeline_inputs calls: list[str] = [] runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=FailingCaldavSynchronizer(), agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "caldav_sync" assert "CalDAV sync failed" in errors[0].message # Pipeline continues to next steps even if caldav_sync fails def test_runner_caldav_sync_failed_status_produces_pipeline_warning( pipeline_inputs: tuple[Lesson, Homework], caldav_failed_result: CalDAVSyncResult, ) -> None: """CalDAV sync result has status=FAILED → PipelineWarning with redacted errors.""" lesson, homework = pipeline_inputs calls: list[str] = [] def failing_sync_result(data: Any, settings: Settings) -> CalDAVSyncResult: """Return a failed sync result. :param data: Data to sync (unused). :param settings: Settings (unused). :return: Failed sync result. :rtype: CalDAVSyncResult """ del data, settings return caldav_failed_result runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=failing_sync_result, agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "caldav_sync" # Verify errors are redacted (should not contain plain secret if present) assert "Error 1" in errors[0].message or "Error 2" in errors[0].message # Pipeline continues to next steps even if caldav_sync fails def test_runner_channel_send_returns_false_produces_pipeline_warning( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """Channel.send() returns False → PipelineWarning with step='send' and 'refusé'.""" lesson, homework = pipeline_inputs calls: list[str] = [] runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), channel=FailingChannel(), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "send" assert "refusé" in errors[0].message def test_runner_channel_send_raises_exception_produces_pipeline_warning( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """Channel.send() raises exception → PipelineWarning with step='send'.""" lesson, homework = pipeline_inputs calls: list[str] = [] runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), channel=ExceptionalChannel(), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "send" assert "XMPP send failed" in errors[0].message def test_runner_pipeline_critical_error_from_non_blocking_step_propagates( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """PipelineCriticalError from non-blocking step propagates and stops pipeline.""" lesson, homework = pipeline_inputs calls: list[str] = [] runner = PipelineRunner( settings=Settings(), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), channel=CriticalChannel(), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() # Pipeline should return None data and critical error in errors list assert data is None assert len(errors) == 1 assert isinstance(errors[0], PipelineCriticalError) assert errors[0].step == "send" assert "Critical send failure" in errors[0].message def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """Blog client returns articles → they appear in XmppMessage.external_info.blog_articles.""" lesson, homework = pipeline_inputs calls: list[str] = [] class SuccessBlogClient: """Blog client returning BlogRSSFetchResult with articles.""" def fetch_and_parse( self, *, known_guids: frozenset[str] | None = None, etag: str | None = None, last_modified: str | None = None, ) -> BlogRSSFetchResult: """Return BlogRSSFetchResult with articles. :param known_guids: Known GUIDs (unused). :param etag: ETag header (unused). :param last_modified: Last-Modified header (unused). :return: BlogRSSFetchResult with articles. :rtype: BlogRSSFetchResult """ del known_guids, etag, last_modified article = BlogArticle( id="article-1", title="Test Article", url="https://example.com/article1", published_at=datetime(2026, 9, 8, 12, 0, tzinfo=datetime.now().astimezone().tzinfo), updated_at=None, category="News", author="Test Author", content_html="
Test content
", content_text="Test content", ) return BlogRSSFetchResult( articles=(article,), etag=None, last_modified=None, not_modified=False, ) runner = PipelineRunner( settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), blog_client=cast("BlogRSSClient | None", SuccessBlogClient()), blog_state=BlogRSSState(), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 0 # Check that channel received message with external_info containing blog_articles channel = runner._channel assert channel is not None assert hasattr(channel, "messages") assert len(channel.messages) == 1 xmpp_message = channel.messages[0] assert xmpp_message.external_info is not None assert len(xmpp_message.external_info.blog_articles) == 1 assert xmpp_message.external_info.blog_articles[0].title == "Test Article" def test_runner_secret_redaction_in_pipeline_errors( pipeline_inputs: tuple[Lesson, Homework], ) -> None: """A configured AI key in a non-URL exception is masked by the runner. The fake blog client embeds the raw API key in plain text, without any URL or ``key=value`` form: only the propagation of ``Settings.redaction_secrets()`` into ``PipelineRunner._redact()`` can mask it. No network call is performed. """ lesson, homework = pipeline_inputs calls: list[str] = [] secret = SecretStr("sk-test-sentinel-12345") class SecretLeakingBlogClient: """Blog client raising an exception containing a raw configured secret.""" def fetch_and_parse( self, *, known_guids: frozenset[str] | None = None, etag: str | None = None, last_modified: str | None = None, ) -> Any: """Raise an exception embedding the raw API key. :param known_guids: Known GUIDs (unused). :param etag: ETag header (unused). :param last_modified: Last-Modified header (unused). :return: Never returns. :raises RuntimeError: Always. """ del known_guids, etag, last_modified raise RuntimeError(f"API key {secret.get_secret_value()} rejected") runner = PipelineRunner( settings=Settings( ai=AISettings(api_key=secret), blog=Settings().blog.model_copy(update={"enabled": True}), ), pronote_fetcher=StubFetcher(calls, lesson, homework), caldav_synchronizer=lambda data, settings: successful_sync_result(), agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)), blog_client=cast("BlogRSSClient | None", SecretLeakingBlogClient()), blog_state=BlogRSSState(), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 1 assert isinstance(errors[0], PipelineWarning) assert errors[0].step == "fetch_blog" # The configured AI key must be masked through _redact(), not by a # generic URL pattern: the message contains no URL nor key=value form. assert secret.get_secret_value() not in errors[0].message assert "REDACTED" in errors[0].message def test_runner_empty_agenda_homework_accepted_as_success() -> None: """Empty agenda and homework lists → pipeline succeeds (no critical error).""" calls: list[str] = [] runner = PipelineRunner( settings=Settings(), pronote_fetcher=EmptyFetcher(), caldav_synchronizer=lambda data, settings: successful_sync_result(), channel=StubChannel(calls), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is not None assert len(errors) == 0 assert data.lessons == [] assert data.homeworks == [] def test_runner_ical_cache_cleanup_on_second_run( monkeypatch: pytest.MonkeyPatch, pipeline_inputs: tuple[Lesson, Homework], ) -> None: """Two independent PipelineRunner.run() calls each trigger their own iCal download.""" import pronote_sync.sources.pronote.fallback as fallback_module lesson, _ = pipeline_inputs settings = Settings( pronote=PronoteSettings( ical_url=SecretStr("https://pronote.example.test/calendar.ics"), agenda_source="ical", homework_source="ical", ) ) fetch_calls: list[str] = [] monkeypatch.setattr( fallback_module, "fetch_ical", lambda url: _record_ical_fetch(fetch_calls, url), ) monkeypatch.setattr(fallback_module, "parse_ical", lambda raw: ([lesson], [], [])) class NoMessageClient: """Minimal pronotepy client for non-critical M11 fetches.""" def get_messages(self) -> list[Message]: """Return no messages. :return: An empty list. :rtype: list[Message] """ return [] def get_informations(self) -> list[Message]: """Return no information messages. :return: An empty list. :rtype: list[Message] """ return [] def get_lessons(self, start: date, end: date) -> list[Lesson]: """Return no fallback lessons. :param start: Start date (unused). :param end: End date (unused). :return: Empty list. :rtype: list[Lesson] """ del start, end return [] def get_homeworks(self, start: date, end: date) -> list[Homework]: """Return no fallback homework. :param start: Start date (unused). :param end: End date (unused). :return: Empty list. :rtype: list[Homework] """ del start, end return [] # First run runner1 = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, NoMessageClient()), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data1, errors1 = runner1.run() # Second run runner2 = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, NoMessageClient()), now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data2, errors2 = runner2.run() assert data1 is not None assert data2 is not None assert errors1 == [] assert errors2 == [] # Each run should have triggered its own iCal download assert fetch_calls == [ "https://pronote.example.test/calendar.ics", "https://pronote.example.test/calendar.ics", ] def test_rotation_error_sends_xmpp_notification() -> None: """Une PronoteAuthRotationError envoie une notification XMPP puis retourne un résultat dégradé. Ce test vérifie que l'erreur de rotation se propage à travers le pipeline réel (PronoteFetcher → fetch_step → PipelineRunner.run) et déclenche une notification XMPP avec un message actionnable. """ calls: list[str] = [] channel = StubChannel(calls) # Créer un client Pronote qui lève PronoteAuthRotationError class RotatingPronoteClient: """Client Pronote qui simule une erreur de rotation de token.""" def get_lessons(self, start: date, end: date) -> list[Lesson]: """Lève l'erreur de rotation lors de la récupération des cours. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Ne retourne jamais. :raises PronoteAuthRotationError: Toujours. """ del start, end raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis") def get_homeworks(self, start: date, end: date) -> list[Homework]: """Ne devrait pas être appelé si fetch_agenda échoue. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Liste vide. :rtype: list[Homework] """ del start, end return [] def get_messages(self) -> list[Message]: """Ne devrait pas être appelé si fetch_agenda échoue. :return: Liste vide. :rtype: list[Message] """ return [] def get_informations(self) -> list[Message]: """Ne devrait pas être appelé si fetch_agenda échoue. :return: Liste vide. :rtype: list[Message] """ return [] settings = Settings( pronote=PronoteSettings( url="https://pronote.example.com", username="test", password=SecretStr("test_password"), ent="bordeaux", account_type="parent", agenda_source="pronotepy", homework_source="pronotepy", ) ) runner = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()), channel=channel, now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is None assert len(errors) == 1 assert isinstance(errors[0], PipelineCriticalError) assert len(channel.messages) == 1 message = channel.messages[0] assert isinstance(message, XmppMessage) assert message.target_date == date(2026, 9, 8) assert message.synthesis is not None assert "Rotation" in message.synthesis assert "token" in message.synthesis assert "QR code" in message.synthesis def test_rotation_error_no_channel_no_xmpp_send() -> None: """Sans canal XMPP, l'erreur de rotation ne tente aucun envoi. Ce test vérifie que même sans canal XMPP configuré, l'erreur de rotation est correctement capturée et retournée dans la liste des erreurs. """ class RotatingPronoteClient: """Client Pronote qui simule une erreur de rotation de token.""" def get_lessons(self, start: date, end: date) -> list[Lesson]: """Lève l'erreur de rotation lors de la récupération des cours. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Ne retourne jamais. :raises PronoteAuthRotationError: Toujours. """ del start, end raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis") def get_homeworks(self, start: date, end: date) -> list[Homework]: """Ne devrait pas être appelé. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Liste vide. :rtype: list[Homework] """ del start, end return [] def get_messages(self) -> list[Message]: """Ne devrait pas être appelé. :return: Liste vide. :rtype: list[Message] """ return [] def get_informations(self) -> list[Message]: """Ne devrait pas être appelé. :return: Liste vide. :rtype: list[Message] """ return [] settings = Settings( pronote=PronoteSettings( url="https://pronote.example.com", username="test", password=SecretStr("test_password"), ent="bordeaux", account_type="parent", agenda_source="pronotepy", homework_source="pronotepy", ) ) runner = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()), channel=None, now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is None assert len(errors) == 1 assert isinstance(errors[0], PipelineCriticalError) def test_rotation_error_dry_run_no_xmpp_send() -> None: """En dry-run, l'erreur de rotation n'envoie aucune notification XMPP. Ce test vérifie que même en mode dry-run, l'erreur de rotation est correctement capturée et retournée, mais aucune notification XMPP n'est envoyée. """ class RotatingPronoteClient: """Client Pronote qui simule une erreur de rotation de token.""" def get_lessons(self, start: date, end: date) -> list[Lesson]: """Lève l'erreur de rotation lors de la récupération des cours. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Ne retourne jamais. :raises PronoteAuthRotationError: Toujours. """ del start, end raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis") def get_homeworks(self, start: date, end: date) -> list[Homework]: """Ne devrait pas être appelé. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Liste vide. :rtype: list[Homework] """ del start, end return [] def get_messages(self) -> list[Message]: """Ne devrait pas être appelé. :return: Liste vide. :rtype: list[Message] """ return [] def get_informations(self) -> list[Message]: """Ne devrait pas être appelé. :return: Liste vide. :rtype: list[Message] """ return [] calls: list[str] = [] channel = StubChannel(calls) settings = Settings( pronote=PronoteSettings( url="https://pronote.example.com", username="test", password=SecretStr("test_password"), ent="bordeaux", account_type="parent", agenda_source="pronotepy", homework_source="pronotepy", messages_source="pronotepy", auth_mode="password", qr_code_file=None, qr_pin=None, ical_url=None, ) ) runner = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()), channel=channel, dry_run=True, now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is None assert len(errors) == 1 assert isinstance(errors[0], PipelineCriticalError) assert channel.messages == [] assert "send" not in calls def test_no_secrets_in_xmpp_message() -> None: """La synthèse XMPP de rotation ne contient aucun secret (token, PIN, URL). Ce test vérifie que le message XMPP généré pour une erreur de rotation ne contient aucun secret sensible, même si l'erreur originale en contenait. """ class RotatingPronoteClient: """Client Pronote qui simule une erreur de rotation avec secrets dans message.""" def get_lessons(self, start: date, end: date) -> list[Lesson]: """Lève l'erreur de rotation avec message contenant des secrets. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Ne retourne jamais. :raises PronoteAuthRotationError: Toujours, avec des secrets dans le message. """ del start, end raise PronoteAuthRotationError( "Token sk-sentinel-token-987654 invalide et PIN 000000 pour " "https://pronote.sentinel.example/icalsecurise" ) def get_homeworks(self, start: date, end: date) -> list[Homework]: """Ne devrait pas être appelé. :param start: Début de la fenêtre (ignoré). :param end: Fin de la fenêtre (ignoré). :return: Liste vide. :rtype: list[Homework] """ del start, end return [] def get_messages(self) -> list[Message]: """Ne devrait pas être appelé. :return: Liste vide. :rtype: list[Message] """ return [] def get_informations(self) -> list[Message]: """Ne devrait pas être appelé. :return: Liste vide. :rtype: list[Message] """ return [] channel = StubChannel([]) settings = Settings( pronote=PronoteSettings( url="https://pronote.example.com", username="test", password=SecretStr("test_password"), ent="bordeaux", account_type="parent", agenda_source="pronotepy", homework_source="pronotepy", ) ) runner = PipelineRunner( settings=settings, pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()), channel=channel, now_provider=lambda: datetime(2026, 9, 8, 7, 0), ) data, errors = runner.run() assert data is None assert len(errors) == 1 assert len(channel.messages) == 1 message = channel.messages[0] assert isinstance(message, XmppMessage) assert message.synthesis is not None # Vérifier que les secrets ne sont pas dans le message final assert "sk-sentinel-token-987654" not in message.synthesis assert "000000" not in message.synthesis assert "pronote.sentinel.example" not in message.synthesis # Vérifier que le message contient les instructions actionnables assert ".pronote_auth_state.json" in message.synthesis assert "PRONOTE_QR_CODE_FILE" in message.synthesis assert "PRONOTE_QR_PIN" in message.synthesis