"""Tests unitaires pour le gestionnaire d'état du flux RSS du blog. Ce module valide le comportement de :class:`BlogRSSState` dans :mod:`pronote_sync.sources.blog.state`. Les tests couvrent : - La persistance des GUID connus et des en-têtes de cache HTTP, - La tolérance aux erreurs (fichier absent, corrompu, version incompatible), - Le tri alphabétique des GUID lors de la sauvegarde, - La réinitialisation complète de l'état. Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``. """ from __future__ import annotations import json from pathlib import Path from unittest.mock import patch import pytest from pronote_sync.sources.blog.state import BlogRSSState def test_state_file_absent_empty_state(tmp_path: Path) -> None: """Vérifie qu'un fichier d'état absent initialise un état vide. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "nonexistent.json" state = BlogRSSState(state_file) assert state.get_known_guids() == frozenset() assert state.get_cache_headers() == (None, None) def test_add_guids_persists(tmp_path: Path) -> None: """Vérifie que l'ajout de GUID persiste dans le fichier JSON. :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(["guid-2", "guid-1", "guid-3"]) assert state.get_known_guids() == frozenset({"guid-1", "guid-2", "guid-3"}) # Vérification du contenu du fichier saved_data = json.loads(state_file.read_text(encoding="utf-8")) assert saved_data["known_guids"] == ["guid-1", "guid-2", "guid-3"] def test_add_guids_empty_noop(tmp_path: Path) -> None: """Vérifie que l'ajout d'une liste vide ne modifie pas le fichier. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "state.json" state = BlogRSSState(state_file) # Ajout initial de GUID state.add_guids(["guid-1"]) original_content = state_file.read_text(encoding="utf-8") # Ajout d'une liste vide state.add_guids([]) # Vérification que le fichier n'a pas été modifié (comparaison par contenu) assert state_file.read_text(encoding="utf-8") == original_content def test_state_load_persisted_guids(tmp_path: Path) -> None: """Vérifie que les GUID persistés sont rechargés dans une nouvelle instance. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "state.json" # Création et sauvegarde de l'état initial state1 = BlogRSSState(state_file) state1.add_guids(["guid-1", "guid-2"]) # Création d'une nouvelle instance avec le même fichier state2 = BlogRSSState(state_file) assert state2.get_known_guids() == frozenset({"guid-1", "guid-2"}) def test_state_load_cache_headers(tmp_path: Path) -> None: """Vérifie que les en-têtes de cache persistés sont rechargés. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "state.json" # Création et sauvegarde des en-têtes de cache state1 = BlogRSSState(state_file) state1.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT") # Création d'une nouvelle instance avec le même fichier state2 = BlogRSSState(state_file) assert state2.get_cache_headers() == ("etag-123", "Wed, 01 Sep 2026 GMT") def test_corrupt_json_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Vérifie qu'un fichier JSON corrompu déclenche un avertissement et initialise un état vide. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :return: None """ state_file = tmp_path / "corrupt.json" state_file.write_text("not json{", encoding="utf-8") with caplog.at_level("WARNING"): state = BlogRSSState(state_file) assert state.get_known_guids() == frozenset() assert state.get_cache_headers() == (None, None) assert "Impossible de charger le fichier d'état blog RSS" in caplog.text def test_wrong_version_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Vérifie qu'une version incompatible déclenche un avertissement et initialise un état vide. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :return: None """ state_file = tmp_path / "wrong_version.json" state_file.write_text( json.dumps({"version": 99, "known_guids": ["x"], "etag": None, "last_modified": None}), encoding="utf-8", ) with caplog.at_level("WARNING"): state = BlogRSSState(state_file) assert state.get_known_guids() == frozenset() assert state.get_cache_headers() == (None, None) assert "version absente ou non supportée" in caplog.text def test_missing_version_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Vérifie qu'un fichier sans champ version déclenche un avertissement et initialise un état vide. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :return: None """ state_file = tmp_path / "missing_version.json" state_file.write_text( json.dumps({"known_guids": ["x"], "etag": None, "last_modified": None}), encoding="utf-8", ) with caplog.at_level("WARNING"): state = BlogRSSState(state_file) assert state.get_known_guids() == frozenset() assert state.get_cache_headers() == (None, None) assert "version absente ou non supportée" in caplog.text def test_known_guids_sorted_on_save(tmp_path: Path) -> None: """Vérifie que les GUID sont triés alphabétiquement lors de la sauvegarde. :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(["c-guid", "a-guid", "b-guid"]) saved_data = json.loads(state_file.read_text(encoding="utf-8")) assert saved_data["known_guids"] == ["a-guid", "b-guid", "c-guid"] def test_clear_resets_state(tmp_path: Path) -> None: """Vérifie que la méthode clear réinitialise complètement l'état. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "state.json" state = BlogRSSState(state_file) # Ajout de GUID et d'en-têtes de cache state.add_guids(["guid-1", "guid-2"]) state.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT") # Réinitialisation state.clear() assert state.get_known_guids() == frozenset() assert state.get_cache_headers() == (None, None) # Vérification du contenu du fichier saved_data = json.loads(state_file.read_text(encoding="utf-8")) assert saved_data["known_guids"] == [] assert saved_data["etag"] is None assert saved_data["last_modified"] is None def test_clear_persists_to_file(tmp_path: Path) -> None: """Vérifie que la réinitialisation est persistée dans le fichier. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "state.json" # Création, ajout de données et réinitialisation state1 = BlogRSSState(state_file) state1.add_guids(["guid-1"]) state1.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT") state1.clear() # Création d'une nouvelle instance avec le même fichier state2 = BlogRSSState(state_file) assert state2.get_known_guids() == frozenset() assert state2.get_cache_headers() == (None, None) def test_str_path_converted_to_path(tmp_path: Path) -> None: """Vérifie qu'un chemin de type str est converti en Path. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = str(tmp_path / "state.json") state = BlogRSSState(state_file) state.add_guids(["guid-1"]) assert Path(state_file).exists() def test_update_cache_headers_none_values(tmp_path: Path) -> None: """Vérifie que la mise à jour avec des valeurs None fonctionne correctement. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "state.json" state = BlogRSSState(state_file) state.update_cache_headers(None, None) assert state.get_cache_headers() == (None, None) # Vérification du contenu du fichier saved_data = json.loads(state_file.read_text(encoding="utf-8")) assert saved_data["etag"] is None assert saved_data["last_modified"] is None def test_add_guids_multiple_calls(tmp_path: Path) -> None: """Vérifie que plusieurs appels à add_guids accumulent les GUID. :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(["guid-1"]) state.add_guids(["guid-2"]) assert state.get_known_guids() == frozenset({"guid-1", "guid-2"}) def test_version_in_saved_file(tmp_path: Path) -> None: """Vérifie que le champ version est présent dans le fichier 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(["guid-1"]) saved_data = json.loads(state_file.read_text(encoding="utf-8")) assert saved_data["version"] == 1 def test_get_known_guids_returns_frozenset(tmp_path: Path) -> None: """Vérifie que get_known_guids retourne un frozenset. :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(["guid-1", "guid-2"]) result = state.get_known_guids() assert type(result) is frozenset def test_atomic_save_preserves_on_error(tmp_path: Path) -> None: """Vérifie que l'état original est préservé en cas d'erreur lors de la sauvegarde atomique. Si une erreur survient pendant le remplacement atomique du fichier, le fichier original doit rester intact et le fichier temporaire doit être nettoyé. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "state.json" # Créer un état initial avec des GUID state = BlogRSSState(state_file) state.add_guids(["original-guid-1", "original-guid-2"]) # Lire le contenu original original_content = state_file.read_text(encoding="utf-8") # Mock Path.replace pour simuler une erreur pendant le remplacement atomique with patch.object(Path, "replace") as mock_replace: mock_replace.side_effect = OSError("Simulated atomic replace failure") # Essayer d'ajouter de nouveaux GUID, ce qui déclenchera _save() state.add_guids(["new-guid"]) # Vérifier que le fichier original est toujours intact assert state_file.read_text(encoding="utf-8") == original_content # Vérifier que le fichier temporaire a été nettoyé tmp_file = state_file.with_suffix(".tmp") assert not tmp_file.exists() # Vérifier que l'état en mémoire n'a pas été modifié (car la sauvegarde a échoué) # Note: En réalité, l'état en mémoire est modifié mais pas persistant # C'est le fichier qui doit rester intact assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"}) # Ensure trailing newline