Compare commits

...
Author SHA1 Message Date
Codex 46d9942c56 fix(blog): confirmer l'état mémoire après sauvegarde 2026-09-13 00:14:38 +02:00
Codex e6f0659cbf fix(caldav): ignorer les événements gérés sans UID
Closes #47

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:13:29 +02:00
5 changed files with 101 additions and 12 deletions
+2 -2
View File
@@ -156,7 +156,7 @@
"filename": "tests/unit/test_caldav_gateway.py", "filename": "tests/unit/test_caldav_gateway.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false, "is_verified": false,
"line_number": 763 "line_number": 794
} }
], ],
"tests/unit/test_caldav_security.py": [ "tests/unit/test_caldav_security.py": [
@@ -185,5 +185,5 @@
} }
] ]
}, },
"generated_at": "2026-09-12T17:57:39Z" "generated_at": "2026-09-12T22:12:56Z"
} }
+26 -9
View File
@@ -97,7 +97,10 @@ class BlogRSSState:
redact_exception(exc), 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. """Sauvegarde l'état dans le fichier JSON de manière atomique.
La sortie est déterministe : ``known_guids`` est trié 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 jamais laisser un fichier partiel en cas d'interruption. En cas
d'erreur d'écriture, une erreur est journalisée sans être d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé. 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: if not self._persistence_enabled:
return return True
known_guids, etag, last_modified = state or (
self._known_guids,
self._etag,
self._last_modified,
)
payload = { payload = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"known_guids": sorted(self._known_guids), "known_guids": sorted(known_guids),
"etag": self._etag, "etag": etag,
"last_modified": self._last_modified, "last_modified": last_modified,
} }
tmp_file = self._state_file.with_suffix(".tmp") tmp_file = self._state_file.with_suffix(".tmp")
try: try:
with open(tmp_file, "w", encoding="utf-8") as handle: with open(tmp_file, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2) json.dump(payload, handle, indent=2)
tmp_file.replace(self._state_file) tmp_file.replace(self._state_file)
return True
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Impossible d'écrire le fichier d'état blog RSS %s : %s.", "Impossible d'écrire le fichier d'état blog RSS %s : %s.",
@@ -134,6 +147,7 @@ class BlogRSSState:
"Nettoyage du fichier temporaire échoué : %s", "Nettoyage du fichier temporaire échoué : %s",
redact_exception(cleanup_exc), redact_exception(cleanup_exc),
) )
return False
def get_known_guids(self) -> frozenset[str]: def get_known_guids(self) -> frozenset[str]:
"""Renvoie une copie immuable des GUID d'articles déjà connus. """Renvoie une copie immuable des GUID d'articles déjà connus.
@@ -168,10 +182,13 @@ class BlogRSSState:
""" """
if result.not_modified: if result.not_modified:
return return
self._known_guids.update(article.id for article in result.articles) new_state = (
self._etag = result.etag self._known_guids | {article.id for article in result.articles},
self._last_modified = result.last_modified result.etag,
self._save() 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]: def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés. """Renvoie les en-têtes de cache HTTP mémorisés.
+5 -1
View File
@@ -191,7 +191,11 @@ class CalDAVGateway:
for vevent in component.walk("VEVENT"): for vevent in component.walk("VEVENT"):
managed = vevent.get(MANAGED_PROPERTY) managed = vevent.get(MANAGED_PROPERTY)
if managed is not None and str(managed) == MANAGED_VALUE: if managed is not None and str(managed) == MANAGED_VALUE:
raw_uid = str(vevent.get("UID")) raw_uid_value = vevent.get("UID")
if raw_uid_value is None or not str(raw_uid_value).strip():
logger.warning("Événement CalDAV géré sans UID ignoré.")
continue
raw_uid = str(raw_uid_value)
canonical_uid = normalize_pronote_uid(raw_uid) canonical_uid = normalize_pronote_uid(raw_uid)
result.append((raw_uid, canonical_uid, vevent)) result.append((raw_uid, canonical_uid, vevent))
except Exception as exc: except Exception as exc:
+37
View File
@@ -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"}) 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="<p>Contenu</p>",
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 # Ensure trailing newline
+31
View File
@@ -327,6 +327,37 @@ def test_list_managed_events_returns_only_managed(
assert str(vevent.get("UID")) == "test-uid-123" assert str(vevent.get("UID")) == "test-uid-123"
def test_list_managed_events_ignores_managed_event_without_uid(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
caplog: LogCaptureFixture,
) -> None:
"""Ignore un événement géré sans UID et ne le transmet pas au planificateur.
:param caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients CalDAV mockée.
:param caplog: Capture des journaux de diagnostic.
:return: None
"""
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
gateway.connect()
malformed_event = Event()
malformed_event.add("SUMMARY", "Événement sans identifiant")
malformed_event.add(MANAGED_PROPERTY, MANAGED_VALUE)
remote_event = MagicMock()
remote_event.icalendar_component = Calendar()
remote_event.icalendar_component.add_component(malformed_event)
calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[0]
calendar.search.return_value = [remote_event]
with caplog.at_level(logging.WARNING):
result = gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31))
assert result == []
assert "sans UID ignoré" in caplog.text
def test_list_managed_events_not_connected_raises( def test_list_managed_events_not_connected_raises(
caldav_settings: CalDAVSettings, caldav_settings: CalDAVSettings,
) -> None: ) -> None: