"""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 AppSettings, PronoteSettings, Settings from pronote_sync.errors import PipelineCriticalError, PipelineWarning from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent 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.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"]