fix(M11): propagate PipelineCriticalError, redact configured secrets, signal blog failures
Correct 4 findings from the independent M11 review: #1 (Critical) — PipelineCriticalError was downgraded to PipelineWarning: - Add except PipelineCriticalError: raise before each except Exception in all 5 non-blocking steps (fetch_blog, compare, caldav_sync, synthesis, send) - Critical errors now propagate to the outer handler and stop the pipeline #2 (Critical) — redact_exception() did not use configured secrets: - Extend redact_exception() with extra_secrets parameter (upward compatible) - Harden redact_secrets(): sort extra_secrets by length descending - Add Settings.redaction_secrets() collecting all 6 SecretStr fields - Add PipelineRunner._redact(exc) using self._redaction_secrets - All except blocks in run() now use self._redact(exc) - CalDAV FAILED-status path uses full redaction_secrets collection #3 (Medium) — BlogRSSClient silently swallowed failures: - Add error field to BlogRSSFetchResult - rss.py sets error on failure paths (except Exception, bozo/invalid feed) - fetch_blog_step raises RuntimeError when result.error is set - PipelineRunner now produces PipelineWarning for blog failures #4 (Medium) — Test coverage at 80%, now 91%: - 11 new integration tests covering blog failure/success, compare failure, CalDAV failure (exception + FAILED status), send False/exception, PipelineCriticalError propagation, secret redaction with sentinel, empty agenda/homework, iCal cache cleanup - Secret redaction test uses mock (no network) and proves configured-secret propagation via non-URL sentinel in RuntimeError Validation: 619 tests pass, ruff/mypy/bandit/pre-commit green, coverage 91%. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -261,3 +261,23 @@ class Settings(BaseSettings):
|
|||||||
ai: AISettings = Field(default_factory=AISettings)
|
ai: AISettings = Field(default_factory=AISettings)
|
||||||
blog: BlogSettings = Field(default_factory=BlogSettings)
|
blog: BlogSettings = Field(default_factory=BlogSettings)
|
||||||
app: AppSettings = Field(default_factory=AppSettings)
|
app: AppSettings = Field(default_factory=AppSettings)
|
||||||
|
|
||||||
|
def redaction_secrets(self) -> tuple[SecretStr, ...]:
|
||||||
|
"""Énumère tous les secrets configurés pour la rédaction.
|
||||||
|
|
||||||
|
Collecte les valeurs :class:`pydantic.SecretStr` non vides présentes
|
||||||
|
dans les sous-configurations (Pronote, CalDAV, XMPP, IA). Les valeurs
|
||||||
|
vides ou ``None`` sont filtrées ; les doublons sont supprimés.
|
||||||
|
|
||||||
|
:return: Tuple de secrets à masquer dans les messages d'erreur.
|
||||||
|
:rtype: tuple[SecretStr, ...]
|
||||||
|
"""
|
||||||
|
secrets = [
|
||||||
|
self.pronote.ical_url,
|
||||||
|
self.pronote.password,
|
||||||
|
self.caldav.url,
|
||||||
|
self.caldav.password,
|
||||||
|
self.xmpp.password,
|
||||||
|
self.ai.api_key,
|
||||||
|
]
|
||||||
|
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None))
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class PipelineRunner:
|
|||||||
:param now_provider: Horloge injectée pour rendre l'exécution testable.
|
:param now_provider: Horloge injectée pour rendre l'exécution testable.
|
||||||
"""
|
"""
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
|
self._redaction_secrets = settings.redaction_secrets()
|
||||||
self._pronote_fetcher = pronote_fetcher
|
self._pronote_fetcher = pronote_fetcher
|
||||||
self._caldav_synchronizer = caldav_synchronizer
|
self._caldav_synchronizer = caldav_synchronizer
|
||||||
self._agenda_comparator = agenda_comparator
|
self._agenda_comparator = agenda_comparator
|
||||||
@@ -153,6 +154,15 @@ class PipelineRunner:
|
|||||||
update={"app": self._settings.app.model_copy(update={"dry_run": self._dry_run})}
|
update={"app": self._settings.app.model_copy(update={"dry_run": self._dry_run})}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _redact(self, exc: Exception) -> str:
|
||||||
|
"""Rédige une exception avec les secrets configurés.
|
||||||
|
|
||||||
|
:param exc: Exception dont le message doit être masqué.
|
||||||
|
:return: Message d'erreur avec secrets configurés remplacés par ``REDACTED``.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
return redact_exception(exc, self._redaction_secrets)
|
||||||
|
|
||||||
def _run_context(self) -> AbstractContextManager[None]:
|
def _run_context(self) -> AbstractContextManager[None]:
|
||||||
"""Retourne le contexte isolant les éventuels caches de source.
|
"""Retourne le contexte isolant les éventuels caches de source.
|
||||||
|
|
||||||
@@ -195,14 +205,18 @@ class PipelineRunner:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
||||||
|
except PipelineCriticalError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("fetch_blog", redact_exception(exc))
|
self._warn("fetch_blog", self._redact(exc))
|
||||||
blog_articles = []
|
blog_articles = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agenda_diff = compare_step(self._agenda_comparator, data)
|
agenda_diff = compare_step(self._agenda_comparator, data)
|
||||||
|
except PipelineCriticalError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("compare", redact_exception(exc))
|
self._warn("compare", self._redact(exc))
|
||||||
from pronote_sync.models.diff import AgendaDiff
|
from pronote_sync.models.diff import AgendaDiff
|
||||||
|
|
||||||
agenda_diff = AgendaDiff(target_date=data.target_date)
|
agenda_diff = AgendaDiff(target_date=data.target_date)
|
||||||
@@ -214,13 +228,13 @@ class PipelineRunner:
|
|||||||
if sync_result.status is CalDAVSyncStatus.FAILED:
|
if sync_result.status is CalDAVSyncStatus.FAILED:
|
||||||
caldav_errors = redact_secrets(
|
caldav_errors = redact_secrets(
|
||||||
"; ".join(sync_result.errors),
|
"; ".join(sync_result.errors),
|
||||||
extra_secrets=(effective_settings.caldav.password,)
|
extra_secrets=self._redaction_secrets,
|
||||||
if effective_settings.caldav.password is not None
|
|
||||||
else (),
|
|
||||||
)
|
)
|
||||||
self._warn("caldav_sync", caldav_errors or "Échec CalDAV")
|
self._warn("caldav_sync", caldav_errors or "Échec CalDAV")
|
||||||
|
except PipelineCriticalError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("caldav_sync", redact_exception(exc))
|
self._warn("caldav_sync", self._redact(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
synthesis = synthesis_step(
|
synthesis = synthesis_step(
|
||||||
@@ -232,8 +246,10 @@ class PipelineRunner:
|
|||||||
target_date=data.target_date,
|
target_date=data.target_date,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
except PipelineCriticalError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("synthesis", redact_exception(exc))
|
self._warn("synthesis", self._redact(exc))
|
||||||
synthesis = None
|
synthesis = None
|
||||||
|
|
||||||
message = XmppMessage(
|
message = XmppMessage(
|
||||||
@@ -250,15 +266,17 @@ class PipelineRunner:
|
|||||||
try:
|
try:
|
||||||
if not send_step(self._channel, message):
|
if not send_step(self._channel, message):
|
||||||
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
||||||
|
except PipelineCriticalError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("send", redact_exception(exc))
|
self._warn("send", self._redact(exc))
|
||||||
return data, [*self._errors, *self._warnings]
|
return data, [*self._errors, *self._warnings]
|
||||||
except PipelineCriticalError as exc:
|
except PipelineCriticalError as exc:
|
||||||
logger.error("Erreur critique du pipeline : %s", exc.message)
|
logger.error("Erreur critique du pipeline : %s", exc.message)
|
||||||
self._errors.append(exc)
|
self._errors.append(exc)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = PipelineCriticalError(
|
error = PipelineCriticalError(
|
||||||
f"Erreur inattendue du pipeline : {redact_exception(exc)}", step="pipeline"
|
f"Erreur inattendue du pipeline : {self._redact(exc)}", step="pipeline"
|
||||||
)
|
)
|
||||||
logger.error("Erreur critique du pipeline : %s", error.message)
|
logger.error("Erreur critique du pipeline : %s", error.message)
|
||||||
self._errors.append(error)
|
self._errors.append(error)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
|
|||||||
result = client.fetch_and_parse(
|
result = client.fetch_and_parse(
|
||||||
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
|
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
|
||||||
)
|
)
|
||||||
|
if result.error is not None:
|
||||||
|
raise RuntimeError(result.error) from None
|
||||||
if not result.not_modified:
|
if not result.not_modified:
|
||||||
state.add_guids(article.id for article in result.articles)
|
state.add_guids(article.id for article in result.articles)
|
||||||
state.update_cache_headers(result.etag, result.last_modified)
|
state.update_cache_headers(result.etag, result.last_modified)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class BlogRSSFetchResult(BaseModel):
|
|||||||
réponse RSS, si elle est disponible. ``None`` par défaut.
|
réponse RSS, si elle est disponible. ``None`` par défaut.
|
||||||
:param not_modified: Vaut ``True`` si le serveur a répondu avec le
|
:param not_modified: Vaut ``True`` si le serveur a répondu avec le
|
||||||
statut ``304 Not Modified``, ``False`` sinon.
|
statut ``304 Not Modified``, ``False`` sinon.
|
||||||
|
:param error: Message d'erreur expurgé si la récupération a échoué,
|
||||||
|
``None`` sinon.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(frozen=True)
|
model_config = ConfigDict(frozen=True)
|
||||||
@@ -52,3 +54,7 @@ class BlogRSSFetchResult(BaseModel):
|
|||||||
default=False,
|
default=False,
|
||||||
description="Vaut True si le serveur a répondu 304 Not Modified",
|
description="Vaut True si le serveur a répondu 304 Not Modified",
|
||||||
)
|
)
|
||||||
|
error: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=("Message d'erreur expurgé si la récupération a échoué, None sinon"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -131,12 +131,14 @@ class BlogRSSClient:
|
|||||||
if getattr(feed, "bozo", None):
|
if getattr(feed, "bozo", None):
|
||||||
bozo_exception = getattr(feed, "bozo_exception", None)
|
bozo_exception = getattr(feed, "bozo_exception", None)
|
||||||
if bozo_exception is not None:
|
if bozo_exception is not None:
|
||||||
|
error_msg = f"Flux RSS invalide : {redact_exception(bozo_exception)}"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Flux RSS du blog invalide (%s), ignoré : %s",
|
"Flux RSS du blog invalide (%s), ignoré : %s",
|
||||||
redact_exception(bozo_exception),
|
redact_exception(bozo_exception),
|
||||||
redact_url(self.rss_url),
|
redact_url(self.rss_url),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
error_msg = "Flux RSS invalide"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Flux RSS du blog invalide, ignoré : %s",
|
"Flux RSS du blog invalide, ignoré : %s",
|
||||||
redact_url(self.rss_url),
|
redact_url(self.rss_url),
|
||||||
@@ -146,6 +148,7 @@ class BlogRSSClient:
|
|||||||
etag=etag,
|
etag=etag,
|
||||||
last_modified=last_modified,
|
last_modified=last_modified,
|
||||||
not_modified=False,
|
not_modified=False,
|
||||||
|
error=error_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
articles: list[BlogArticle] = []
|
articles: list[BlogArticle] = []
|
||||||
@@ -234,16 +237,18 @@ class BlogRSSClient:
|
|||||||
not_modified=False,
|
not_modified=False,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
error_msg = redact_exception(exc)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Échec de la récupération du flux RSS du blog %s : %s",
|
"Échec de la récupération du flux RSS du blog %s : %s",
|
||||||
redact_url(self.rss_url),
|
redact_url(self.rss_url),
|
||||||
redact_exception(exc),
|
error_msg,
|
||||||
)
|
)
|
||||||
return BlogRSSFetchResult(
|
return BlogRSSFetchResult(
|
||||||
articles=(),
|
articles=(),
|
||||||
etag=etag,
|
etag=etag,
|
||||||
last_modified=last_modified,
|
last_modified=last_modified,
|
||||||
not_modified=False,
|
not_modified=False,
|
||||||
|
error=error_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
|
|||||||
(clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées
|
(clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées
|
||||||
littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y
|
littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y
|
||||||
compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur``
|
compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur``
|
||||||
reconnue. Une valeur vide ou ``None`` est ignorée.
|
reconnue. Une valeur vide ou ``None`` est ignorée. Les secrets sont
|
||||||
|
appliqués du plus long au plus court afin qu'un secret qui est une
|
||||||
|
sous-chaîne d'un autre soit remplacé en premier, sans être corrompu.
|
||||||
|
|
||||||
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
||||||
:param extra_secrets: Itérable de secrets bruts (``str`` ou
|
:param extra_secrets: Itérable de secrets bruts (``str`` ou
|
||||||
@@ -101,19 +103,25 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
|
|||||||
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
||||||
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
|
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
|
||||||
redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
|
redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
|
||||||
|
values: list[str] = []
|
||||||
for secret in extra_secrets:
|
for secret in extra_secrets:
|
||||||
value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
|
value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
|
values.append(value)
|
||||||
|
for value in sorted(values, key=len, reverse=True):
|
||||||
redacted = redacted.replace(value, _REDACTED)
|
redacted = redacted.replace(value, _REDACTED)
|
||||||
return redacted
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
def redact_exception(exc: Exception) -> str:
|
def redact_exception(exc: Exception, extra_secrets: Iterable[SecretStr | str] = ()) -> str:
|
||||||
"""Masque les secrets dans la représentation textuelle d'une exception.
|
"""Masque les secrets dans la représentation textuelle d'une exception.
|
||||||
|
|
||||||
:param exc: Exception dont le message doit être rédigé.
|
:param exc: Exception dont le message doit être rédigé.
|
||||||
|
:param extra_secrets: Itérable de secrets bruts (``str`` ou
|
||||||
|
:class:`pydantic.SecretStr`) à masquer, transmis à
|
||||||
|
:func:`redact_secrets`. Les valeurs vides ou ``None`` sont ignorées.
|
||||||
:return: Représentation textuelle de l'exception avec les secrets masqués.
|
:return: Représentation textuelle de l'exception avec les secrets masqués.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
return redact_secrets(str(exc))
|
return redact_secrets(str(exc), extra_secrets)
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ from typing import Any, cast
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
|
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
|
||||||
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
|
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
|
||||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
|
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.diff import AgendaDiff
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
from pronote_sync.models.message import Message
|
from pronote_sync.models.message import Message
|
||||||
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
|
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
|
||||||
from pronote_sync.pipeline.run import PipelineRunner
|
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.sources.pronote.fallback import PronoteFetcher
|
||||||
from pronote_sync.sync.diff import AgendaComparator
|
from pronote_sync.sync.diff import AgendaComparator
|
||||||
|
|
||||||
@@ -464,3 +468,598 @@ def test_runner_reuses_ical_download_and_parse_within_one_run(
|
|||||||
assert data is not None
|
assert data is not None
|
||||||
assert errors == []
|
assert errors == []
|
||||||
assert fetch_calls == ["https://pronote.example.test/calendar.ics"]
|
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="<p>Test content</p>",
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user