From f261fed1af06873cbb6109767a61796d0df5ded2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 00:15:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(blog):=20confirmer=20l'=C3=A9tat=20m=C3=A9m?= =?UTF-8?q?oire=20apr=C3=A8s=20sauvegarde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #49 Co-authored-by: Codex --- pronote_sync/sources/blog/state.py | 35 ++++++++++++++++++++-------- tests/unit/test_blog_state.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/pronote_sync/sources/blog/state.py b/pronote_sync/sources/blog/state.py index 648c4c9..15574a0 100644 --- a/pronote_sync/sources/blog/state.py +++ b/pronote_sync/sources/blog/state.py @@ -97,7 +97,10 @@ class BlogRSSState: redact_exception(exc), ) - def _save(self) -> None: + def _save( + self, + state: tuple[set[str], str | None, str | None] | None = None, + ) -> bool: """Sauvegarde l'état dans le fichier JSON de manière atomique. La sortie est déterministe : ``known_guids`` est trié @@ -107,20 +110,30 @@ class BlogRSSState: jamais laisser un fichier partiel en cas d'interruption. En cas d'erreur d'écriture, une erreur est journalisée sans être propagée et le fichier temporaire est supprimé. + + :param state: État à sauvegarder ; l'état courant est utilisé par défaut. + :return: ``True`` si l'état a été sauvegardé ou si la persistance est désactivée. + :rtype: bool """ if not self._persistence_enabled: - return + return True + known_guids, etag, last_modified = state or ( + self._known_guids, + self._etag, + self._last_modified, + ) payload = { "version": _STATE_VERSION, - "known_guids": sorted(self._known_guids), - "etag": self._etag, - "last_modified": self._last_modified, + "known_guids": sorted(known_guids), + "etag": etag, + "last_modified": last_modified, } tmp_file = self._state_file.with_suffix(".tmp") try: with open(tmp_file, "w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2) tmp_file.replace(self._state_file) + return True except Exception as exc: logger.error( "Impossible d'écrire le fichier d'état blog RSS %s : %s.", @@ -134,6 +147,7 @@ class BlogRSSState: "Nettoyage du fichier temporaire échoué : %s", redact_exception(cleanup_exc), ) + return False def get_known_guids(self) -> frozenset[str]: """Renvoie une copie immuable des GUID d'articles déjà connus. @@ -168,10 +182,13 @@ class BlogRSSState: """ 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() + new_state = ( + self._known_guids | {article.id for article in result.articles}, + result.etag, + result.last_modified, + ) + if self._save(new_state): + self._known_guids, self._etag, self._last_modified = new_state def get_cache_headers(self) -> tuple[str | None, str | None]: """Renvoie les en-têtes de cache HTTP mémorisés. diff --git a/tests/unit/test_blog_state.py b/tests/unit/test_blog_state.py index b8f29b8..d80554d 100644 --- a/tests/unit/test_blog_state.py +++ b/tests/unit/test_blog_state.py @@ -425,4 +425,41 @@ def test_atomic_save_preserves_on_error(tmp_path: Path) -> None: assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"}) +def test_acknowledge_does_not_advance_memory_when_save_fails( + tmp_path: Path, +) -> None: + """Conserve l'état précédent en mémoire si l'acquittement ne peut pas être sauvegardé. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / "state.json" + state = BlogRSSState(state_file) + state.add_guids(["existing-guid"]) + state.update_cache_headers("old-etag", "old-last-modified") + article = BlogArticle( + id="new-guid", + 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", + ) + + with patch.object(Path, "replace", side_effect=OSError("replace failed")): + state.acknowledge( + BlogRSSFetchResult( + articles=(article,), + etag="new-etag", + last_modified="new-last-modified", + ) + ) + + assert state.get_known_guids() == frozenset({"existing-guid"}) + assert state.get_cache_headers() == ("old-etag", "old-last-modified") + + # Ensure trailing newline