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>
725 lines
24 KiB
Python
725 lines
24 KiB
Python
"""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"
|