diff --git a/docs/exploitation.md b/docs/exploitation.md index 859f600..3e8c102 100644 --- a/docs/exploitation.md +++ b/docs/exploitation.md @@ -71,6 +71,13 @@ exporté depuis le site web Pronote. Si le compte exige un second facteur, configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs. + +Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de +l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run` +laisse donc les articles récupérables à l'exécution suivante ; les en-têtes +HTTP associés à ces articles suivent la même règle pour éviter un `304` qui +masquerait une livraison non confirmée. + En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier `.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode @@ -110,10 +117,17 @@ sudo systemctl start pronote-sync.service sudo systemctl status pronote-sync.service ``` -Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`. -La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un -échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système -de supervision) relève de l'exploitation locale. +La CLI expose un contrat de sortie stable : `0` signifie une exécution complète, +`2` une exécution dégradée (les données Pronote sont disponibles mais une étape +optionnelle, CalDAV ou XMPP a échoué), et `1` un échec critique. Tout code non +nul laisse l'unité `pronote-sync.service` en état `failed` ; la supervision doit +donc alerter sur cet état ou sur le code de sortie. Le code `2` permet de +distinguer automatiquement une alerte dégradée d'une panne critique, sans lire +les journaux. + +Le `--dry-run` n'écrit ni dans CalDAV/XMPP ni dans l'état local. Il conserve le +même contrat de codes : `0` si la simulation est complète, `2` si elle est +dégradée et `1` si elle est critique. ## Journaux et alertes diff --git a/pronote_sync/cli/main.py b/pronote_sync/cli/main.py index e5b47f0..b2e3e0c 100644 --- a/pronote_sync/cli/main.py +++ b/pronote_sync/cli/main.py @@ -11,6 +11,7 @@ from pydantic import SecretStr from pronote_sync.config.env import load_settings from pronote_sync.config.settings import Settings +from pronote_sync.errors import ErrorSeverity, PipelineError from pronote_sync.pipeline.run import PipelineRunner from pronote_sync.utils.logging import setup_logging from pronote_sync.utils.redaction import redact_secrets @@ -19,6 +20,26 @@ logger = logging.getLogger(__name__) _LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") +# Contrat stable pour systemd et les outils de supervision. +EXIT_SUCCESS = 0 +EXIT_CRITICAL = 1 +EXIT_DEGRADED = 2 + + +def _pipeline_exit_code(data: object | None, errors: Sequence[PipelineError]) -> int: + """Convertit le résultat du pipeline en code de sortie supervisable. + + :param data: Données normalisées produites, ou ``None`` en cas d'échec critique. + :param errors: Erreurs et avertissements de l'exécution. + :return: ``0`` si complet, ``2`` si dégradé, ``1`` si critique. + :rtype: int + """ + if data is None or any(error.severity == ErrorSeverity.CRITICAL for error in errors): + return EXIT_CRITICAL + if errors: + return EXIT_DEGRADED + return EXIT_SUCCESS + def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace: """Analyse les options de lancement du programme. @@ -120,7 +141,7 @@ def main(arguments: Sequence[str] | None = None) -> int: bruts afin de préserver le diagnostic sans exposer de secret. :param arguments: Arguments optionnels, principalement utiles aux appels programmatiques. - :return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments). + :return: Code machine-readable : ``0`` complet, ``2`` dégradé, ``1`` critique. :rtype: int :raises SystemExit: Si argparse rejette les arguments (code de sortie 2). """ @@ -147,9 +168,7 @@ def main(arguments: Sequence[str] | None = None) -> int: secrets = _settings_secrets(settings) for error in errors: logger.error("%s", redact_secrets(error.message, extra_secrets=secrets)) - if data is None: - return 1 - return 0 + return _pipeline_exit_code(data, errors) if __name__ == "__main__": diff --git a/pronote_sync/pipeline/run.py b/pronote_sync/pipeline/run.py index 02fe79d..83c8c1f 100644 --- a/pronote_sync/pipeline/run.py +++ b/pronote_sync/pipeline/run.py @@ -230,11 +230,13 @@ class PipelineRunner: data = normalize_step(fetched, generated_at=now) try: - blog_articles = fetch_blog_step(self._blog_client, self._blog_state) + blog_result = fetch_blog_step(self._blog_client, self._blog_state) + blog_articles = list(blog_result.articles) except PipelineCriticalError: raise except Exception as exc: self._warn("fetch_blog", self._redact(exc)) + blog_result = None blog_articles = [] try: @@ -290,8 +292,11 @@ class PipelineRunner: ) if self._channel is not None and not self._dry_run: try: - if not send_step(self._channel, message): + delivered = send_step(self._channel, message) + if not delivered: self._warn("send", "Le canal XMPP a refusé l'envoi") + elif blog_result is not None and self._blog_state is not None: + self._blog_state.acknowledge(blog_result) except PipelineCriticalError: raise except Exception as exc: diff --git a/pronote_sync/pipeline/steps/fetch_blog.py b/pronote_sync/pipeline/steps/fetch_blog.py index 282bd05..b2bfbff 100644 --- a/pronote_sync/pipeline/steps/fetch_blog.py +++ b/pronote_sync/pipeline/steps/fetch_blog.py @@ -2,23 +2,28 @@ from __future__ import annotations -from pronote_sync.models.blog import BlogArticle +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.utils.redaction import redact_exception -def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]: - """Récupère les articles RSS nouveaux en conservant l'état du client. +def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult: + """Récupère les articles RSS nouveaux sans les acquitter. + + L'état des GUID est acquitté séparément par le pipeline après confirmation + de la livraison XMPP. Les en-têtes de cache d'une réponse sans article + peuvent être conservés immédiatement, car aucune livraison n'est alors en + attente. :param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé. :param state: État de déduplication et de cache HTTP associé au run. - :return: Nouveaux articles du blog. - :rtype: list[BlogArticle] + :return: Résultat de récupération, incluant les métadonnées de cache. + :rtype: BlogRSSFetchResult :raises RuntimeError: Si la récupération RSS injectée échoue. """ if client is None or state is None: - return [] + return BlogRSSFetchResult() try: etag, last_modified = state.get_cache_headers() result = client.fetch_and_parse( @@ -26,9 +31,8 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> ) if result.error is not None: raise RuntimeError(result.error) from None - if not result.not_modified: - state.add_guids(article.id for article in result.articles) + if not result.not_modified and not result.articles: state.update_cache_headers(result.etag, result.last_modified) - return list(result.articles) + return result except Exception as exc: raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None diff --git a/pronote_sync/sources/blog/state.py b/pronote_sync/sources/blog/state.py index 6e5de47..648c4c9 100644 --- a/pronote_sync/sources/blog/state.py +++ b/pronote_sync/sources/blog/state.py @@ -19,6 +19,7 @@ import logging from collections.abc import Iterable from pathlib import Path +from pronote_sync.sources.blog.result import BlogRSSFetchResult from pronote_sync.utils.redaction import redact_exception, redact_secrets logger = logging.getLogger(__name__) @@ -156,6 +157,22 @@ class BlogRSSState: self._known_guids.update(new_guids) self._save() + def acknowledge(self, result: BlogRSSFetchResult) -> None: + """Acquitte une récupération RSS après sa livraison confirmée. + + Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un + article dont la livraison a échoué reste récupérable à l'exécution + suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter. + + :param result: Résultat RSS livré avec succès. + """ + if result.not_modified: + return + self._known_guids.update(article.id for article in result.articles) + self._etag = result.etag + self._last_modified = result.last_modified + self._save() + def get_cache_headers(self) -> tuple[str | None, str | None]: """Renvoie les en-têtes de cache HTTP mémorisés. diff --git a/pronote_sync/sync/serialization.py b/pronote_sync/sync/serialization.py index 399bbeb..c3e63fa 100644 --- a/pronote_sync/sync/serialization.py +++ b/pronote_sync/sync/serialization.py @@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier. from __future__ import annotations -from datetime import datetime, time +from datetime import datetime, timedelta from typing import cast from icalendar import Calendar, Component, Event, vDate, vDatetime @@ -33,7 +33,15 @@ MANAGED_VALUE = "v1" PRODID = "-//pronote-sync//NONSGML v1.0//EN" #: Propriétés prises en compte dans la signature sémantique d'un composant. -_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION") +_SIGNATURE_KEYS: tuple[str, ...] = ( + "UID", + "SUMMARY", + "DTSTART", + "DTEND", + "STATUS", + "TRANSP", + "DESCRIPTION", +) def lesson_to_vevent(lesson: Lesson) -> Event: @@ -88,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event: def homework_to_vevent(homework: Homework) -> Event: """Convertit un devoir Pronote en composant VEVENT iCalendar. - Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la - journée d'échéance, entre 08:00 et 18:00. + Le devoir est représenté comme un événement toute la journée à la date + d'échéance. Il est transparent pour ne pas bloquer les disponibilités ; + aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``. :param homework: Devoir Pronote à sérialiser. :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. @@ -98,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event: event = Event() event.add("uid", f"homework-{homework.id}") event.add("summary", f"Devoir: {homework.subject}") - event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0)))) - event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0)))) + event.add("dtstart", vDate(homework.due_on)) + event.add("dtend", vDate(homework.due_on + timedelta(days=1))) event.add("description", homework.text) - event.add("status", "NEEDS-ACTION") + event.add("transp", "TRANSPARENT") event.add("categories", ["Pronote", "Devoir"]) event.add(MANAGED_PROPERTY, MANAGED_VALUE) return event diff --git a/tests/e2e/test_cli.py b/tests/e2e/test_cli.py index d298b1f..54e4d98 100644 --- a/tests/e2e/test_cli.py +++ b/tests/e2e/test_cli.py @@ -52,10 +52,10 @@ def test_main_runs_composition_root_in_dry_run_with_requested_log_level( runner.run.assert_called_once_with() -def test_main_preserves_configured_dry_run_and_returns_success_with_warnings( +def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings( mocker: MockerFixture, ) -> None: - """Sans option, la CLI préserve le dry-run configuré et accepte les avertissements.""" + """Sans option, la CLI préserve le dry-run configuré et signale l'état dégradé.""" from pronote_sync.cli.main import main settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING")) @@ -72,12 +72,32 @@ def test_main_preserves_configured_dry_run_and_returns_success_with_warnings( exit_code = main([]) - assert exit_code == 0 + assert exit_code == 2 assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")] composition_root.assert_called_once_with(settings, dry_run=None) runner.run.assert_called_once_with() +@pytest.mark.parametrize("step", ["caldav_sync", "send"]) +def test_main_returns_degraded_code_for_caldav_or_xmpp_failure( + mocker: MockerFixture, + step: str, +) -> None: + """Les échecs récupérables CalDAV et XMPP sont observables par le code 2.""" + from pronote_sync.cli.main import main + + settings = Settings() + mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings) + runner = mocker.Mock() + runner.run.return_value = ( + mocker.Mock(spec=PronoteData), + [PipelineWarning(f"Échec récupérable de {step}", step=step)], + ) + mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner) + + assert main([]) == 2 + + def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level( mocker: MockerFixture, capsys: pytest.CaptureFixture[str], diff --git a/tests/integration/test_caldav_sync.py b/tests/integration/test_caldav_sync.py index 8f4f4e1..488b39e 100644 --- a/tests/integration/test_caldav_sync.py +++ b/tests/integration/test_caldav_sync.py @@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from typing import TYPE_CHECKING, Any import pytest @@ -122,10 +122,16 @@ class FakeCalendar: event_start = raw_start.dt event_end = raw_end.dt if raw_end is not None else event_start overlaps = True - if start is not None: - overlaps = overlaps and event_end > start - if end is not None: - overlaps = overlaps and event_start < end + if isinstance(event_start, datetime): + if start is not None: + overlaps = overlaps and event_end > start + if end is not None: + overlaps = overlaps and event_start < end + else: + if start is not None: + overlaps = overlaps and event_end > start.date() + if end is not None: + overlaps = overlaps and event_start < end.date() if overlaps: results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server)) break @@ -686,6 +692,80 @@ class TestCalDAVSynchronize: assert result.added == 1 assert len(fake_caldav_server.get_events()) == 1 + def test_legacy_homework_vevent_is_migrated_idempotently_and_removed( + self, + fake_caldav_server: FakeCalDAVServer, + full_settings: Settings, + ) -> None: + """Migre un ancien devoir puis vérifie l'idempotence et la suppression.""" + fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text( + uid="homework-hw-legacy", + summary="Devoir: Histoire", + start=datetime(2026, 1, 20, 8, 0), + end=datetime(2026, 1, 20, 18, 0), + status="NEEDS-ACTION", + managed=True, + ) + homework = Homework( + id="hw-legacy", + subject="Histoire", + assigned_on=None, + due_on=date(2026, 1, 20), + text="Lire le chapitre 5", + ) + + def make_data(homeworks: list[Homework]) -> PronoteData: + """Construit les données de synchronisation du scénario.""" + return PronoteData( + lessons=[], + homeworks=homeworks, + school_events=[], + messages=[], + target_date=date(2026, 1, 20), + generated_at=datetime(2026, 1, 14, 0, 0), + ) + + first = synchronize( + pronote_data=make_data([homework]), + settings=full_settings, + client_factory=fake_caldav_server.client_factory, + now=datetime(2026, 1, 14, 12, 0), + ) + assert first.status == CalDAVSyncStatus.SUCCESS + assert first.added == 0 + assert first.updated == 1 + assert first.removed == 0 + + migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"]) + event = migrated.walk("VEVENT")[0] + assert event.get("DTSTART").dt == date(2026, 1, 20) + assert event.get("DTEND").dt == date(2026, 1, 21) + assert event.get("STATUS") is None + assert str(event.get("TRANSP")) == "TRANSPARENT" + + second = synchronize( + pronote_data=make_data([homework]), + settings=full_settings, + client_factory=fake_caldav_server.client_factory, + now=datetime(2026, 1, 14, 12, 0), + ) + assert second.status == CalDAVSyncStatus.SKIPPED + assert second.added == 0 + assert second.updated == 0 + assert second.removed == 0 + + removed = synchronize( + pronote_data=make_data([]), + settings=full_settings, + client_factory=fake_caldav_server.client_factory, + now=datetime(2026, 1, 14, 12, 0), + ) + assert removed.status == CalDAVSyncStatus.SUCCESS + assert removed.added == 0 + assert removed.updated == 0 + assert removed.removed == 1 + assert fake_caldav_server.get_events() == {} + def test_school_event_sync( self, fake_caldav_server: FakeCalDAVServer, diff --git a/tests/integration/test_pipeline_runner.py b/tests/integration/test_pipeline_runner.py index 562dad8..39d430d 100644 --- a/tests/integration/test_pipeline_runner.py +++ b/tests/integration/test_pipeline_runner.py @@ -1040,6 +1040,110 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info( assert xmpp_message.external_info.blog_articles[0].title == "Test Article" +@pytest.mark.parametrize( + ("channel_kind", "dry_run", "should_acknowledge"), + [ + ("success", False, True), + ("false", False, False), + ("exception", False, False), + ("none", False, False), + ("success", True, False), + ], +) +def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery( + pipeline_inputs: tuple[Lesson, Homework], + tmp_path: Any, + channel_kind: str, + dry_run: bool, + should_acknowledge: bool, +) -> None: + """Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi. + + :param pipeline_inputs: Données Pronote de test. + :param tmp_path: Répertoire temporaire pour l'état RSS. + :param channel_kind: Comportement du canal XMPP simulé. + :param dry_run: Active ou non le mode simulation. + :param should_acknowledge: Indique si l'état RSS doit être acquitté. + """ + lesson, homework = pipeline_inputs + calls: list[str] = [] + state_file = tmp_path / "blog-state.json" + + class SuccessfulBlogClient: + """Client RSS renvoyant un article non encore livré.""" + + def fetch_and_parse( + self, + *, + known_guids: frozenset[str] | None = None, + etag: str | None = None, + last_modified: str | None = None, + ) -> BlogRSSFetchResult: + """Retourne un article et des en-têtes de cache déterministes. + + :param known_guids: GUID déjà connus, ignorés dans ce faux client. + :param etag: ETag mémorisé, ignoré dans ce faux client. + :param last_modified: Date HTTP mémorisée, ignorée dans ce faux client. + :return: Résultat RSS avec un article à livrer. + :rtype: BlogRSSFetchResult + """ + del known_guids, etag, last_modified + return BlogRSSFetchResult( + articles=( + BlogArticle( + id="article-to-deliver", + title="Article à livrer", + url="https://example.com/article-to-deliver", + published_at=datetime(2026, 9, 8, 12, 0), + updated_at=None, + category=None, + author=None, + content_html="

Contenu

", + content_text="Contenu", + ), + ), + etag="etag-after-delivery", + last_modified="Tue, 08 Sep 2026 12:00:00 GMT", + ) + + channel: Any + if channel_kind == "success": + channel = StubChannel(calls) + elif channel_kind == "false": + channel = FailingChannel() + elif channel_kind == "exception": + channel = ExceptionalChannel() + else: + channel = None + + 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", SuccessfulBlogClient()), + blog_state=BlogRSSState(state_file), + channel=channel, + dry_run=dry_run, + now_provider=lambda: datetime(2026, 9, 8, 7, 0), + ) + + data, errors = runner.run() + + assert data is not None + if should_acknowledge: + acknowledged_state = BlogRSSState(state_file) + assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"}) + assert acknowledged_state.get_cache_headers() == ( + "etag-after-delivery", + "Tue, 08 Sep 2026 12:00:00 GMT", + ) + else: + assert not state_file.exists() + if channel_kind in {"false", "exception"}: + assert any(error.step == "send" for error in errors) + + def test_runner_secret_redaction_in_pipeline_errors( pipeline_inputs: tuple[Lesson, Homework], ) -> None: diff --git a/tests/unit/test_blog_state.py b/tests/unit/test_blog_state.py index 4d2bc57..b8f29b8 100644 --- a/tests/unit/test_blog_state.py +++ b/tests/unit/test_blog_state.py @@ -14,11 +14,14 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``. from __future__ import annotations import json +from datetime import UTC, datetime from pathlib import Path from unittest.mock import patch import pytest +from pronote_sync.models.blog import BlogArticle +from pronote_sync.sources.blog.result import BlogRSSFetchResult from pronote_sync.sources.blog.state import BlogRSSState @@ -116,6 +119,38 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None: assert state_file.read_text(encoding="utf-8") == original_content +def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None: + """Vérifie l'acquittement atomique après une livraison confirmée. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / "state.json" + state = BlogRSSState(state_file) + article = BlogArticle( + id="guid-1", + title="Article", + url="https://example.com/article", + published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC), + updated_at=None, + category=None, + author=None, + content_html="

Contenu

", + content_text="Contenu", + ) + + state.acknowledge( + BlogRSSFetchResult( + articles=(article,), + etag="etag-1", + last_modified="Sat, 12 Sep 2026 08:00:00 GMT", + ) + ) + + assert state.get_known_guids() == frozenset({"guid-1"}) + assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT") + + def test_state_load_persisted_guids(tmp_path: Path) -> None: """Vérifie que les GUID persistés sont rechargés dans une nouvelle instance. diff --git a/tests/unit/test_sync_serialization.py b/tests/unit/test_sync_serialization.py index 3a364a1..5c8b54f 100644 --- a/tests/unit/test_sync_serialization.py +++ b/tests/unit/test_sync_serialization.py @@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None: assert str(event.get("UID")) == "homework-HW-5678" -def test_homework_to_vevent_status() -> None: - """Vérifie qu'un devoir a STATUS=NEEDS-ACTION. +def test_homework_to_vevent_is_transparent_without_task_status() -> None: + """Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO. :return: None """ homework = _make_homework() event = homework_to_vevent(homework) - assert str(event.get("STATUS")) == "NEEDS-ACTION" + assert event.get("STATUS") is None + assert str(event.get("TRANSP")) == "TRANSPARENT" def test_homework_to_vevent_categories() -> None: @@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None: def test_homework_to_vevent_dtstart_dtend() -> None: - """Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00). + """Vérifie que DTSTART et DTEND encadrent la journée d'échéance. :return: None """ homework = _make_homework(due_on=date(2026, 1, 20)) event = homework_to_vevent(homework) - assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0) - assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0) + assert event.get("DTSTART").dt == date(2026, 1, 20) + assert event.get("DTEND").dt == date(2026, 1, 21) def test_homework_to_vevent_summary() -> None: