"""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 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.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.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_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", ]