feat(M5): source blog RSS — fetch, parsing, déduplication et état persistant
Implémentation complète de la source blog RSS du collège : - BlogRSSClient (sources/blog/rss.py) : client sans état récupérant et parsant le flux via feedparser, avec déduplication par ensemble de GUIDs connus, cache HTTP conditionnel (ETag/Last-Modified), conversion HTML→texte (BeautifulSoup), tri déterministe (date desc puis id asc), et mode dégradé (flux invalide/erreur → warning expurgé + liste vide). - BlogRSSFetchResult (sources/blog/result.py) : résultat immuable contenant articles, en-têtes de cache et indicateur not_modified. - BlogRSSState (sources/blog/state.py) : persistance JSON tolérante (GUIDs triés, version, ETag, Last-Modified) avec redaction des chemins dans les logs. - Fixture tests/fixtures/blog_rss.xml : flux RSS 2.0 anonymisé, 3 articles, dates fixes, ordre non chronologique. - 38 tests unitaires (22 client + 16 state) couvrant parsing nominal, déduplication intra-flux, 304, bozo, erreurs réseau, non-fuite de secrets, tri secondaire, persistance d'état et tolérance aux fichiers corrompus. - Documentation : TODO.md M5 coché, GUIDE_DEV_PYTHON.md §5 bis aligné avec l'API livrée (known_guids, BlogRSSFetchResult, BlogRSSState). - Configuration : feedparser ajouté aux additional_dependencies du hook mypy pre-commit pour aligner l'environnement isolé avec le .venv. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
47
tests/fixtures/blog_rss.xml
vendored
Normal file
47
tests/fixtures/blog_rss.xml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Blog du collège Les Mimosas</title>
|
||||
<link>https://example.com/blog/</link>
|
||||
<description>Actualités et informations du collège Les Mimosas</description>
|
||||
<language>fr-FR</language>
|
||||
<item>
|
||||
<title>Information générale</title>
|
||||
<link>https://example.com/blog/?p=1003</link>
|
||||
<guid isPermaLink="false">https://example.com/blog/?p=1003</guid>
|
||||
<pubDate>Wed, 12 Aug 2026 08:00:00 +0000</pubDate>
|
||||
<description>Information générale à destination des familles.</description>
|
||||
<content:encoded><![CDATA[
|
||||
<p>La vie scolaire rappelle aux familles que les billets de cantine sont à commander avant le vendredi soir.</p>
|
||||
<p>Pour toute question, consultez la page <a href="https://example.com/blog/cantine/">cantines et restauration</a> du site.</p>
|
||||
]]></content:encoded>
|
||||
</item>
|
||||
<item>
|
||||
<title>Réunion de rentrée</title>
|
||||
<link>https://example.com/blog/?p=1001</link>
|
||||
<guid isPermaLink="false">https://example.com/blog/?p=1001</guid>
|
||||
<pubDate>Mon, 10 Aug 2026 09:00:11 +0000</pubDate>
|
||||
<category>Administration</category>
|
||||
<dc:creator>M. Dupont</dc:creator>
|
||||
<description>Réunion de rentrée des parents d'élèves.</description>
|
||||
<content:encoded><![CDATA[
|
||||
<p>La réunion de rentrée des parents d'élèves se tiendra le mardi 15 septembre à 18 h 00 dans la salle polyvalente.</p>
|
||||
<p>L'équipe pédagogique y présentera le projet d'établissement et le calendrier des conseils de classe. Un temps d'échange est prévu avec les professeurs principaux.</p>
|
||||
<p>Merci de confirmer votre présence en remplissant le <a href="https://example.com/blog/reunion-rentree-inscription/">formulaire d'inscription</a> avant le 10 septembre.</p>
|
||||
]]></content:encoded>
|
||||
</item>
|
||||
<item>
|
||||
<title>Sortie pédagogique au musée</title>
|
||||
<link>https://example.com/blog/?p=1002</link>
|
||||
<guid isPermaLink="false">https://example.com/blog/?p=1002</guid>
|
||||
<pubDate>Tue, 11 Aug 2026 14:30:00 +0000</pubDate>
|
||||
<category>Pédagogie</category>
|
||||
<description>Sortie pédagogique des élèves de 4e au musée d'art moderne.</description>
|
||||
<content:encoded><![CDATA[
|
||||
<p>Les élèves de 4e se rendront au musée d'art moderne le jeudi 8 octobre dans le cadre du cours d'arts plastiques.</p>
|
||||
<p>La visite guidée portera sur la période impressionniste. Les élèves devront apporter un carnet de croquis et leur pique-nique.</p>
|
||||
<p>Le détail de l'organisation figure dans la <a href="https://example.com/blog/sortie-musee-autorisation/">note d'autorisation</a> à retourner signée avant le 25 septembre.</p>
|
||||
]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
724
tests/unit/test_blog_client.py
Normal file
724
tests/unit/test_blog_client.py
Normal file
@@ -0,0 +1,724 @@
|
||||
"""Tests unitaires pour le client RSS du blog du collège.
|
||||
|
||||
Ce module vérifie le comportement du client :class:`BlogRSSClient` pour la
|
||||
récupération et le parsing du flux RSS du blog. Tous les tests sont unitaires
|
||||
et utilisent des mocks pour éviter tout accès réseau réel.
|
||||
|
||||
Les tests couvrent :
|
||||
- Le parsing nominal du flux RSS avec déduplication et tri
|
||||
- La gestion des réponses 304 Not Modified
|
||||
- La gestion des flux invalides (bozo)
|
||||
- La gestion des erreurs réseau
|
||||
- La conversion HTML vers texte
|
||||
- L'absence de fuite de secrets dans les logs
|
||||
- Les méthodes statiques de parsing de dates et de conversion HTML
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import feedparser # type: ignore[import-untyped]
|
||||
import pytest
|
||||
import pytest_mock
|
||||
|
||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blog_rss_fixture_path() -> Path:
|
||||
"""Chemin vers le fichier fixture RSS du blog.
|
||||
|
||||
:return: Chemin absolu vers le fichier XML de test.
|
||||
:rtype: Path
|
||||
"""
|
||||
return Path(__file__).parent.parent / "fixtures" / "blog_rss.xml"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_parsed_feed(blog_rss_fixture_path: Path) -> feedparser.FeedParserDict:
|
||||
"""Résultat réel du parsing du fixture RSS par feedparser.
|
||||
|
||||
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||
:return: Objet FeedParserDict parsé.
|
||||
:rtype: feedparser.FeedParserDict
|
||||
"""
|
||||
return feedparser.parse(str(blog_rss_fixture_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blog_client(blog_rss_fixture_path: Path) -> BlogRSSClient:
|
||||
"""Instance de BlogRSSClient pointant vers le fixture local.
|
||||
|
||||
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||
:return: Instance de BlogRSSClient.
|
||||
:rtype: BlogRSSClient
|
||||
"""
|
||||
return BlogRSSClient(rss_url=f"file://{blog_rss_fixture_path}")
|
||||
|
||||
|
||||
# --- Helper functions for mocking ---
|
||||
|
||||
|
||||
def make_mock_entry(
|
||||
entry_id: str | None = None,
|
||||
link: str | None = None,
|
||||
title: str = "Test Article",
|
||||
published_parsed: tuple[int, ...] | None = None,
|
||||
pubdate_parsed: tuple[int, ...] | None = None,
|
||||
updated_parsed: tuple[int, ...] | None = None,
|
||||
content: list[dict[str, Any]] | None = None,
|
||||
description: str = "",
|
||||
tags: list[dict[str, Any]] | None = None,
|
||||
category: str | None = None,
|
||||
author: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Crée une entrée RSS mockée sous forme de dict.
|
||||
|
||||
:param entry_id: GUID de l'entrée.
|
||||
:param link: URL de l'entrée.
|
||||
:param title: Titre de l'entrée.
|
||||
:param published_parsed: Date de publication parsée (struct_time).
|
||||
:param pubdate_parsed: Date de publication alternative (struct_time).
|
||||
:param updated_parsed: Date de mise à jour parsée (struct_time).
|
||||
:param content: Contenu HTML de l'entrée.
|
||||
:param description: Description de l'entrée.
|
||||
:param tags: Tags de l'entrée.
|
||||
:param category: Catégorie de l'entrée.
|
||||
:param author: Auteur de l'entrée.
|
||||
:return: Dict représentant une entrée RSS.
|
||||
"""
|
||||
entry: dict[str, Any] = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"author": author,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
}
|
||||
if entry_id is not None:
|
||||
entry["id"] = entry_id
|
||||
if link is not None:
|
||||
entry["link"] = link
|
||||
if published_parsed is not None:
|
||||
entry["published_parsed"] = published_parsed
|
||||
if pubdate_parsed is not None:
|
||||
entry["pubdate_parsed"] = pubdate_parsed
|
||||
if updated_parsed is not None:
|
||||
entry["updated_parsed"] = updated_parsed
|
||||
if content is not None:
|
||||
entry["content"] = content
|
||||
return entry
|
||||
|
||||
|
||||
def make_mock_feed(
|
||||
entries: list[dict[str, Any]] | None = None,
|
||||
status: int = 200,
|
||||
bozo: int = 0,
|
||||
etag: str | None = None,
|
||||
modified: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
bozo_exception: Exception | None = None,
|
||||
) -> Any:
|
||||
"""Crée un objet FeedParserDict mocké.
|
||||
|
||||
:param entries: Liste des entrées RSS.
|
||||
:param status: Code de statut HTTP.
|
||||
:param bozo: Indicateur d'erreur de parsing.
|
||||
:param etag: Valeur de l'en-tête ETag.
|
||||
:param modified: Valeur de l'en-tête Last-Modified.
|
||||
:param headers: En-têtes HTTP de la réponse.
|
||||
:param bozo_exception: Exception associée à l'erreur de parsing.
|
||||
:return: Objet FeedParserDict mocké.
|
||||
"""
|
||||
# feedparser.FeedParserDict est un dict-like, mais nous utilisons un objet
|
||||
# dynamique pour simuler les attributs nécessaires
|
||||
feed = type("FeedParserDict", (), {})()
|
||||
feed.entries = entries or []
|
||||
feed.status = status
|
||||
feed.bozo = bozo
|
||||
feed.etag = etag
|
||||
feed.modified = modified
|
||||
feed.headers = headers or {}
|
||||
if bozo_exception:
|
||||
feed.bozo_exception = bozo_exception
|
||||
return feed
|
||||
|
||||
|
||||
# --- Tests ---
|
||||
|
||||
|
||||
def test_fetch_and_parse_nominal(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
real_parsed_feed: feedparser.FeedParserDict,
|
||||
blog_rss_fixture_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie le parsing nominal du flux RSS avec 3 articles triés par date.
|
||||
|
||||
Le fixture contient 3 articles dans un ordre non chronologique.
|
||||
Le résultat doit contenir les 3 articles triés par date décroissante.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||
:return: None
|
||||
"""
|
||||
# Mock feedparser.parse pour retourner le résultat réel du parsing
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=real_parsed_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
# Vérification du nombre d'articles
|
||||
assert len(result.articles) == 3
|
||||
|
||||
# Vérification de l'ordre : p=1003 (12 Aug), p=1002 (11 Aug), p=1001 (10 Aug)
|
||||
assert result.articles[0].id == "https://example.com/blog/?p=1003"
|
||||
assert result.articles[1].id == "https://example.com/blog/?p=1002"
|
||||
assert result.articles[2].id == "https://example.com/blog/?p=1001"
|
||||
|
||||
# Vérification des titres
|
||||
assert result.articles[0].title == "Information générale"
|
||||
assert result.articles[1].title == "Sortie pédagogique au musée"
|
||||
assert result.articles[2].title == "Réunion de rentrée"
|
||||
|
||||
# Vérification des catégories
|
||||
assert result.articles[0].category is None # Pas de catégorie pour p=1003
|
||||
assert result.articles[1].category == "Pédagogie"
|
||||
assert result.articles[2].category == "Administration"
|
||||
|
||||
# Vérification des auteurs
|
||||
assert result.articles[0].author is None # Pas d'auteur pour p=1003
|
||||
assert result.articles[1].author is None # Pas d'auteur pour p=1002
|
||||
assert result.articles[2].author == "M. Dupont"
|
||||
|
||||
# Vérification des dates de publication
|
||||
assert result.articles[0].published_at == datetime(2026, 8, 12, 8, 0, 0, tzinfo=UTC)
|
||||
assert result.articles[1].published_at == datetime(2026, 8, 11, 14, 30, 0, tzinfo=UTC)
|
||||
assert result.articles[2].published_at == datetime(2026, 8, 10, 9, 0, 11, tzinfo=UTC)
|
||||
|
||||
# Vérification que content_text contient des mots attendus
|
||||
assert "cantine" in result.articles[0].content_text
|
||||
assert "musée" in result.articles[1].content_text
|
||||
assert "réunion" in result.articles[2].content_text.lower()
|
||||
|
||||
|
||||
def test_fetch_and_parse_deduplication(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
real_parsed_feed: feedparser.FeedParserDict,
|
||||
) -> None:
|
||||
"""Vérifie la déduplication avec known_guids.
|
||||
|
||||
Avec known_guids contenant p=1001, seuls p=1002 et p=1003 doivent être retournés.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||
:return: None
|
||||
"""
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=real_parsed_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse(known_guids=frozenset({"https://example.com/blog/?p=1001"}))
|
||||
|
||||
assert len(result.articles) == 2
|
||||
# p=1001 doit être exclu
|
||||
article_ids = {article.id for article in result.articles}
|
||||
assert "https://example.com/blog/?p=1001" not in article_ids
|
||||
assert "https://example.com/blog/?p=1002" in article_ids
|
||||
assert "https://example.com/blog/?p=1003" in article_ids
|
||||
|
||||
|
||||
def test_fetch_and_parse_empty_known_guids(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
real_parsed_feed: feedparser.FeedParserDict,
|
||||
) -> None:
|
||||
"""Vérifie que known_guids vide (frozenset()) retourne tous les articles.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||
:return: None
|
||||
"""
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=real_parsed_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse(known_guids=frozenset())
|
||||
|
||||
assert len(result.articles) == 3
|
||||
|
||||
|
||||
def test_fetch_and_parse_known_guids_none(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
real_parsed_feed: feedparser.FeedParserDict,
|
||||
) -> None:
|
||||
"""Vérifie que known_guids=None retourne tous les articles.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||
:return: None
|
||||
"""
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=real_parsed_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse(known_guids=None)
|
||||
|
||||
assert len(result.articles) == 3
|
||||
|
||||
|
||||
def test_fetch_and_parse_304_not_modified(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie la gestion de la réponse 304 Not Modified.
|
||||
|
||||
Doit retourner not_modified=True, articles vide, et conserver les en-têtes d'entrée.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
mock_feed = make_mock_feed(status=304, etag="old_etag", modified="old_modified")
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=mock_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse(
|
||||
etag="input_etag",
|
||||
last_modified="input_last_modified",
|
||||
)
|
||||
|
||||
assert result.not_modified is True
|
||||
assert result.articles == ()
|
||||
assert result.etag == "input_etag"
|
||||
assert result.last_modified == "input_last_modified"
|
||||
|
||||
|
||||
def test_fetch_and_parse_bozo_invalid_feed(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie la gestion d'un flux invalide (bozo=1).
|
||||
|
||||
Doit retourner articles vide, not_modified=False, et logger un avertissement.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
mock_exception = ValueError("Invalid XML")
|
||||
mock_feed = make_mock_feed(
|
||||
bozo=1,
|
||||
bozo_exception=mock_exception,
|
||||
etag="test_etag",
|
||||
modified="test_modified",
|
||||
)
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=mock_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
assert result.articles == ()
|
||||
assert result.not_modified is False
|
||||
assert result.etag == "test_etag"
|
||||
assert result.last_modified == "test_modified"
|
||||
|
||||
# Vérification du log d'avertissement
|
||||
assert "Flux RSS du blog invalide" in caplog.text
|
||||
# L'URL est présente dans le log mais sans secrets (pas de paramètres sensibles)
|
||||
assert "blog/feed" in caplog.text
|
||||
|
||||
|
||||
def test_fetch_and_parse_network_error(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie la gestion d'une erreur réseau.
|
||||
|
||||
Doit retourner articles vide, not_modified=False, logger une erreur, et ne pas propager l'exception.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
with caplog.at_level(logging.ERROR):
|
||||
mock_exception = ConnectionError("Network error")
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
side_effect=mock_exception,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
# Ne doit pas lever d'exception
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
assert result.articles == ()
|
||||
assert result.not_modified is False
|
||||
|
||||
# Vérification du log d'erreur
|
||||
assert "Échec de la récupération du flux RSS du blog" in caplog.text
|
||||
|
||||
|
||||
def test_fetch_and_parse_no_secret_leak_in_logs(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie qu'aucun secret ne fuit dans les logs en cas d'erreur.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Exception contenant un secret
|
||||
secret = "SECRET_TOKEN_123" # pragma: allowlist secret
|
||||
mock_exception = ValueError(f"Error with token: {secret}")
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
side_effect=mock_exception,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url=f"https://example.com/blog/feed?token={secret}")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
assert result.articles == ()
|
||||
assert result.not_modified is False
|
||||
|
||||
# Vérification qu'aucun secret ne fuit dans les logs
|
||||
assert secret not in caplog.text
|
||||
|
||||
|
||||
def test_fetch_and_parse_entry_without_date(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie qu'une entrée sans date de publication est ignorée.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
entry = make_mock_entry(
|
||||
entry_id="test-id",
|
||||
link="https://example.com/test",
|
||||
title="Test Article",
|
||||
published_parsed=None,
|
||||
pubdate_parsed=None,
|
||||
)
|
||||
mock_feed = make_mock_feed(entries=[entry])
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=mock_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
assert len(result.articles) == 0
|
||||
assert "Entrée RSS sans date de publication valide" in caplog.text
|
||||
|
||||
|
||||
def test_fetch_and_parse_entry_without_guid(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie qu'une entrée sans GUID ni lien est ignorée.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
entry = make_mock_entry(
|
||||
entry_id=None,
|
||||
link=None,
|
||||
title="Test Article",
|
||||
published_parsed=(2026, 8, 10, 9, 0, 11, 0, 222, 0),
|
||||
)
|
||||
mock_feed = make_mock_feed(entries=[entry])
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=mock_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
assert len(result.articles) == 0
|
||||
assert "Entrée RSS sans GUID ni lien" in caplog.text
|
||||
|
||||
|
||||
def test_fetch_and_parse_html_to_text_conversion(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
real_parsed_feed: feedparser.FeedParserDict,
|
||||
) -> None:
|
||||
"""Vérifie que content_text ne contient pas de balises HTML.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||
:return: None
|
||||
"""
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=real_parsed_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
for article in result.articles:
|
||||
# Vérification qu'il n'y a pas de balises HTML dans content_text
|
||||
assert "<p>" not in article.content_text
|
||||
assert "</p>" not in article.content_text
|
||||
assert "<a" not in article.content_text
|
||||
assert "</a>" not in article.content_text
|
||||
assert "<![CDATA[" not in article.content_text
|
||||
|
||||
# Vérification que le contenu textuel est présent
|
||||
assert len(article.content_text) > 0
|
||||
|
||||
|
||||
def test_fetch_and_parse_etag_last_modified_returned(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie que les en-têtes ETag et Last-Modified sont retournés.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
mock_feed = make_mock_feed(
|
||||
entries=[],
|
||||
etag="abc123",
|
||||
modified="Wed, 01 Sep 2026 00:00:00 GMT",
|
||||
)
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=mock_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
assert result.etag == "abc123"
|
||||
assert result.last_modified == "Wed, 01 Sep 2026 00:00:00 GMT"
|
||||
assert result.not_modified is False
|
||||
|
||||
|
||||
def test_fetch_and_parse_passes_etag_to_feedparser(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie que les paramètres etag et last_modified sont passés à feedparser.parse.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
mock_parse = mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=make_mock_feed(),
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
client.fetch_and_parse(
|
||||
etag="abc123",
|
||||
last_modified="Wed, 01 Sep 2026 00:00:00 GMT",
|
||||
)
|
||||
|
||||
# Vérification que feedparser.parse a été appelé avec les bons paramètres
|
||||
mock_parse.assert_called_once()
|
||||
call_args = mock_parse.call_args
|
||||
assert call_args[1]["etag"] == "abc123"
|
||||
assert call_args[1]["modified"] == "Wed, 01 Sep 2026 00:00:00 GMT"
|
||||
|
||||
|
||||
def test_parse_date_valid() -> None:
|
||||
"""Vérifie le parsing d'une date valide.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
date_tuple = (2026, 8, 10, 9, 0, 11, 0, 222, 0)
|
||||
result = BlogRSSClient._parse_date(date_tuple)
|
||||
|
||||
expected = datetime(2026, 8, 10, 9, 0, 11, tzinfo=UTC)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_parse_date_none() -> None:
|
||||
"""Vérifie le parsing d'une date None.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
result = BlogRSSClient._parse_date(None)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_date_invalid() -> None:
|
||||
"""Vérifie le parsing d'une date invalide (tuple trop court).
|
||||
|
||||
:return: None
|
||||
"""
|
||||
result = BlogRSSClient._parse_date((2026,))
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_html_to_text_basic() -> None:
|
||||
"""Vérifie la conversion HTML vers texte de base.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
html = "<p>Hello <a href='x'>world</a></p>"
|
||||
result = BlogRSSClient._html_to_text(html)
|
||||
|
||||
assert result == "Hello world"
|
||||
|
||||
|
||||
def test_html_to_text_empty() -> None:
|
||||
"""Vérifie la conversion HTML vers texte avec une chaîne vide.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
result = BlogRSSClient._html_to_text("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_html_to_text_entities() -> None:
|
||||
"""Vérifie la conversion HTML vers texte avec des entités HTML.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
html = "<p>Café & croissant</p>"
|
||||
result = BlogRSSClient._html_to_text(html)
|
||||
|
||||
assert result == "Café & croissant"
|
||||
|
||||
|
||||
def test_fetch_and_parse_sort_deterministic(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
real_parsed_feed: feedparser.FeedParserDict,
|
||||
) -> None:
|
||||
"""Vérifie que le tri des articles est déterministe.
|
||||
|
||||
Deux appels successifs doivent retourner les articles dans le même ordre.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||
:return: None
|
||||
"""
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=real_parsed_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
|
||||
result1 = client.fetch_and_parse()
|
||||
result2 = client.fetch_and_parse()
|
||||
|
||||
# Vérification que les deux résultats sont identiques
|
||||
assert len(result1.articles) == len(result2.articles)
|
||||
for article1, article2 in zip(result1.articles, result2.articles, strict=True):
|
||||
assert article1.id == article2.id
|
||||
assert article1.title == article2.title
|
||||
assert article1.published_at == article2.published_at
|
||||
|
||||
# Vérification de l'ordre : p=1003 (Aug 12), p=1002 (Aug 11), p=1001 (Aug 10)
|
||||
assert result1.articles[0].id == "https://example.com/blog/?p=1003"
|
||||
assert result1.articles[1].id == "https://example.com/blog/?p=1002"
|
||||
assert result1.articles[2].id == "https://example.com/blog/?p=1001"
|
||||
|
||||
|
||||
def test_fetch_and_parse_duplicate_guid_in_feed(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie la déduplication des entrées avec le même GUID dans un même flux.
|
||||
|
||||
Deux entrées avec le même GUID (même id et même link) doivent donner un seul article.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
# Création de deux entrées avec le même GUID
|
||||
entry1 = make_mock_entry(
|
||||
entry_id="duplicate-guid",
|
||||
link="https://example.com/duplicate",
|
||||
title="First Article",
|
||||
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
|
||||
)
|
||||
entry2 = make_mock_entry(
|
||||
entry_id="duplicate-guid",
|
||||
link="https://example.com/duplicate",
|
||||
title="Second Article",
|
||||
published_parsed=(2026, 8, 11, 10, 0, 0, 0, 223, 0),
|
||||
)
|
||||
|
||||
mock_feed = make_mock_feed(entries=[entry1, entry2])
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=mock_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse(known_guids=None)
|
||||
|
||||
# Doit retourner un seul article (le second duplicata est ignoré)
|
||||
assert len(result.articles) == 1
|
||||
# Le premier article doit être conservé (celui avec la date la plus ancienne)
|
||||
assert result.articles[0].id == "duplicate-guid"
|
||||
assert result.articles[0].title == "First Article"
|
||||
|
||||
|
||||
def test_fetch_and_parse_sort_tied_dates_by_id(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie le tri secondaire par identifiant croissant pour les articles de même date.
|
||||
|
||||
Deux articles avec la même date de publication doivent être triés par id croissant.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
# Création de deux entrées avec la même date mais des id différents
|
||||
entry_a = make_mock_entry(
|
||||
entry_id="guid-a",
|
||||
link="https://example.com/a",
|
||||
title="Article A",
|
||||
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
|
||||
)
|
||||
entry_b = make_mock_entry(
|
||||
entry_id="guid-b",
|
||||
link="https://example.com/b",
|
||||
title="Article B",
|
||||
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
|
||||
)
|
||||
|
||||
mock_feed = make_mock_feed(entries=[entry_b, entry_a]) # Ordre initial : b, a
|
||||
mocker.patch(
|
||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||
return_value=mock_feed,
|
||||
)
|
||||
|
||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
||||
result = client.fetch_and_parse()
|
||||
|
||||
# Doit retourner les deux articles triés par id croissant
|
||||
assert len(result.articles) == 2
|
||||
assert result.articles[0].id == "guid-a" # a doit venir avant b
|
||||
assert result.articles[1].id == "guid-b"
|
||||
311
tests/unit/test_blog_state.py
Normal file
311
tests/unit/test_blog_state.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""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
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
Reference in New Issue
Block a user