Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6575a5f60b |
@@ -66,12 +66,6 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
|
|||||||
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
||||||
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
||||||
|
|
||||||
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
|
|
||||||
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
|
|
||||||
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
|
|
||||||
HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
|
|
||||||
masquerait une livraison non confirmée.
|
|
||||||
|
|
||||||
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
|
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
|
||||||
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
|
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
|
||||||
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
|
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
|
||||||
|
|||||||
@@ -230,13 +230,11 @@ class PipelineRunner:
|
|||||||
data = normalize_step(fetched, generated_at=now)
|
data = normalize_step(fetched, generated_at=now)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
blog_result = fetch_blog_step(self._blog_client, self._blog_state)
|
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
||||||
blog_articles = list(blog_result.articles)
|
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("fetch_blog", self._redact(exc))
|
self._warn("fetch_blog", self._redact(exc))
|
||||||
blog_result = None
|
|
||||||
blog_articles = []
|
blog_articles = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -292,11 +290,8 @@ class PipelineRunner:
|
|||||||
)
|
)
|
||||||
if self._channel is not None and not self._dry_run:
|
if self._channel is not None and not self._dry_run:
|
||||||
try:
|
try:
|
||||||
delivered = send_step(self._channel, message)
|
if not send_step(self._channel, message):
|
||||||
if not delivered:
|
|
||||||
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
||||||
elif blog_result is not None and self._blog_state is not None:
|
|
||||||
self._blog_state.acknowledge(blog_result)
|
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -2,28 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
from pronote_sync.models.blog import BlogArticle
|
||||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
from pronote_sync.utils.redaction import redact_exception
|
from pronote_sync.utils.redaction import redact_exception
|
||||||
|
|
||||||
|
|
||||||
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
|
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
|
||||||
"""Récupère les articles RSS nouveaux sans les acquitter.
|
"""Récupère les articles RSS nouveaux en conservant l'état du client.
|
||||||
|
|
||||||
L'état des GUID est acquitté séparément par le pipeline après confirmation
|
|
||||||
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
|
|
||||||
peuvent être conservés immédiatement, car aucune livraison n'est alors en
|
|
||||||
attente.
|
|
||||||
|
|
||||||
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
||||||
:param state: État de déduplication et de cache HTTP associé au run.
|
:param state: État de déduplication et de cache HTTP associé au run.
|
||||||
:return: Résultat de récupération, incluant les métadonnées de cache.
|
:return: Nouveaux articles du blog.
|
||||||
:rtype: BlogRSSFetchResult
|
:rtype: list[BlogArticle]
|
||||||
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
||||||
"""
|
"""
|
||||||
if client is None or state is None:
|
if client is None or state is None:
|
||||||
return BlogRSSFetchResult()
|
return []
|
||||||
try:
|
try:
|
||||||
etag, last_modified = state.get_cache_headers()
|
etag, last_modified = state.get_cache_headers()
|
||||||
result = client.fetch_and_parse(
|
result = client.fetch_and_parse(
|
||||||
@@ -31,8 +26,9 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
|
|||||||
)
|
)
|
||||||
if result.error is not None:
|
if result.error is not None:
|
||||||
raise RuntimeError(result.error) from None
|
raise RuntimeError(result.error) from None
|
||||||
if not result.not_modified and not result.articles:
|
if not result.not_modified:
|
||||||
|
state.add_guids(article.id for article in result.articles)
|
||||||
state.update_cache_headers(result.etag, result.last_modified)
|
state.update_cache_headers(result.etag, result.last_modified)
|
||||||
return result
|
return list(result.articles)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import logging
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
|
||||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -157,22 +156,6 @@ class BlogRSSState:
|
|||||||
self._known_guids.update(new_guids)
|
self._known_guids.update(new_guids)
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
def acknowledge(self, result: BlogRSSFetchResult) -> None:
|
|
||||||
"""Acquitte une récupération RSS après sa livraison confirmée.
|
|
||||||
|
|
||||||
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
|
|
||||||
article dont la livraison a échoué reste récupérable à l'exécution
|
|
||||||
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
|
|
||||||
|
|
||||||
:param result: Résultat RSS livré avec succès.
|
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, time
|
from datetime import datetime, timedelta
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
||||||
@@ -33,7 +33,15 @@ MANAGED_VALUE = "v1"
|
|||||||
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
||||||
|
|
||||||
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
||||||
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION")
|
_SIGNATURE_KEYS: tuple[str, ...] = (
|
||||||
|
"UID",
|
||||||
|
"SUMMARY",
|
||||||
|
"DTSTART",
|
||||||
|
"DTEND",
|
||||||
|
"STATUS",
|
||||||
|
"TRANSP",
|
||||||
|
"DESCRIPTION",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def lesson_to_vevent(lesson: Lesson) -> Event:
|
def lesson_to_vevent(lesson: Lesson) -> Event:
|
||||||
@@ -88,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
|
|||||||
def homework_to_vevent(homework: Homework) -> Event:
|
def homework_to_vevent(homework: Homework) -> Event:
|
||||||
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
||||||
|
|
||||||
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la
|
Le devoir est représenté comme un événement toute la journée à la date
|
||||||
journée d'échéance, entre 08:00 et 18:00.
|
d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
|
||||||
|
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
|
||||||
|
|
||||||
:param homework: Devoir Pronote à sérialiser.
|
:param homework: Devoir Pronote à sérialiser.
|
||||||
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
||||||
@@ -98,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event:
|
|||||||
event = Event()
|
event = Event()
|
||||||
event.add("uid", f"homework-{homework.id}")
|
event.add("uid", f"homework-{homework.id}")
|
||||||
event.add("summary", f"Devoir: {homework.subject}")
|
event.add("summary", f"Devoir: {homework.subject}")
|
||||||
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0))))
|
event.add("dtstart", vDate(homework.due_on))
|
||||||
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0))))
|
event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
|
||||||
event.add("description", homework.text)
|
event.add("description", homework.text)
|
||||||
event.add("status", "NEEDS-ACTION")
|
event.add("transp", "TRANSPARENT")
|
||||||
event.add("categories", ["Pronote", "Devoir"])
|
event.add("categories", ["Pronote", "Devoir"])
|
||||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||||
return event
|
return event
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -122,10 +122,16 @@ class FakeCalendar:
|
|||||||
event_start = raw_start.dt
|
event_start = raw_start.dt
|
||||||
event_end = raw_end.dt if raw_end is not None else event_start
|
event_end = raw_end.dt if raw_end is not None else event_start
|
||||||
overlaps = True
|
overlaps = True
|
||||||
|
if isinstance(event_start, datetime):
|
||||||
if start is not None:
|
if start is not None:
|
||||||
overlaps = overlaps and event_end > start
|
overlaps = overlaps and event_end > start
|
||||||
if end is not None:
|
if end is not None:
|
||||||
overlaps = overlaps and event_start < end
|
overlaps = overlaps and event_start < end
|
||||||
|
else:
|
||||||
|
if start is not None:
|
||||||
|
overlaps = overlaps and event_end > start.date()
|
||||||
|
if end is not None:
|
||||||
|
overlaps = overlaps and event_start < end.date()
|
||||||
if overlaps:
|
if overlaps:
|
||||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||||
break
|
break
|
||||||
@@ -686,6 +692,80 @@ class TestCalDAVSynchronize:
|
|||||||
assert result.added == 1
|
assert result.added == 1
|
||||||
assert len(fake_caldav_server.get_events()) == 1
|
assert len(fake_caldav_server.get_events()) == 1
|
||||||
|
|
||||||
|
def test_legacy_homework_vevent_is_migrated_idempotently_and_removed(
|
||||||
|
self,
|
||||||
|
fake_caldav_server: FakeCalDAVServer,
|
||||||
|
full_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
"""Migre un ancien devoir puis vérifie l'idempotence et la suppression."""
|
||||||
|
fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text(
|
||||||
|
uid="homework-hw-legacy",
|
||||||
|
summary="Devoir: Histoire",
|
||||||
|
start=datetime(2026, 1, 20, 8, 0),
|
||||||
|
end=datetime(2026, 1, 20, 18, 0),
|
||||||
|
status="NEEDS-ACTION",
|
||||||
|
managed=True,
|
||||||
|
)
|
||||||
|
homework = Homework(
|
||||||
|
id="hw-legacy",
|
||||||
|
subject="Histoire",
|
||||||
|
assigned_on=None,
|
||||||
|
due_on=date(2026, 1, 20),
|
||||||
|
text="Lire le chapitre 5",
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_data(homeworks: list[Homework]) -> PronoteData:
|
||||||
|
"""Construit les données de synchronisation du scénario."""
|
||||||
|
return PronoteData(
|
||||||
|
lessons=[],
|
||||||
|
homeworks=homeworks,
|
||||||
|
school_events=[],
|
||||||
|
messages=[],
|
||||||
|
target_date=date(2026, 1, 20),
|
||||||
|
generated_at=datetime(2026, 1, 14, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = synchronize(
|
||||||
|
pronote_data=make_data([homework]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert first.status == CalDAVSyncStatus.SUCCESS
|
||||||
|
assert first.added == 0
|
||||||
|
assert first.updated == 1
|
||||||
|
assert first.removed == 0
|
||||||
|
|
||||||
|
migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"])
|
||||||
|
event = migrated.walk("VEVENT")[0]
|
||||||
|
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
||||||
|
assert event.get("DTEND").dt == date(2026, 1, 21)
|
||||||
|
assert event.get("STATUS") is None
|
||||||
|
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
||||||
|
|
||||||
|
second = synchronize(
|
||||||
|
pronote_data=make_data([homework]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert second.status == CalDAVSyncStatus.SKIPPED
|
||||||
|
assert second.added == 0
|
||||||
|
assert second.updated == 0
|
||||||
|
assert second.removed == 0
|
||||||
|
|
||||||
|
removed = synchronize(
|
||||||
|
pronote_data=make_data([]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert removed.status == CalDAVSyncStatus.SUCCESS
|
||||||
|
assert removed.added == 0
|
||||||
|
assert removed.updated == 0
|
||||||
|
assert removed.removed == 1
|
||||||
|
assert fake_caldav_server.get_events() == {}
|
||||||
|
|
||||||
def test_school_event_sync(
|
def test_school_event_sync(
|
||||||
self,
|
self,
|
||||||
fake_caldav_server: FakeCalDAVServer,
|
fake_caldav_server: FakeCalDAVServer,
|
||||||
|
|||||||
@@ -1040,110 +1040,6 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
|
|||||||
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("channel_kind", "dry_run", "should_acknowledge"),
|
|
||||||
[
|
|
||||||
("success", False, True),
|
|
||||||
("false", False, False),
|
|
||||||
("exception", False, False),
|
|
||||||
("none", False, False),
|
|
||||||
("success", True, False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
|
|
||||||
pipeline_inputs: tuple[Lesson, Homework],
|
|
||||||
tmp_path: Any,
|
|
||||||
channel_kind: str,
|
|
||||||
dry_run: bool,
|
|
||||||
should_acknowledge: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
|
|
||||||
|
|
||||||
:param pipeline_inputs: Données Pronote de test.
|
|
||||||
:param tmp_path: Répertoire temporaire pour l'état RSS.
|
|
||||||
:param channel_kind: Comportement du canal XMPP simulé.
|
|
||||||
:param dry_run: Active ou non le mode simulation.
|
|
||||||
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
|
|
||||||
"""
|
|
||||||
lesson, homework = pipeline_inputs
|
|
||||||
calls: list[str] = []
|
|
||||||
state_file = tmp_path / "blog-state.json"
|
|
||||||
|
|
||||||
class SuccessfulBlogClient:
|
|
||||||
"""Client RSS renvoyant un article non encore livré."""
|
|
||||||
|
|
||||||
def fetch_and_parse(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
known_guids: frozenset[str] | None = None,
|
|
||||||
etag: str | None = None,
|
|
||||||
last_modified: str | None = None,
|
|
||||||
) -> BlogRSSFetchResult:
|
|
||||||
"""Retourne un article et des en-têtes de cache déterministes.
|
|
||||||
|
|
||||||
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
|
|
||||||
:param etag: ETag mémorisé, ignoré dans ce faux client.
|
|
||||||
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
|
|
||||||
:return: Résultat RSS avec un article à livrer.
|
|
||||||
:rtype: BlogRSSFetchResult
|
|
||||||
"""
|
|
||||||
del known_guids, etag, last_modified
|
|
||||||
return BlogRSSFetchResult(
|
|
||||||
articles=(
|
|
||||||
BlogArticle(
|
|
||||||
id="article-to-deliver",
|
|
||||||
title="Article à livrer",
|
|
||||||
url="https://example.com/article-to-deliver",
|
|
||||||
published_at=datetime(2026, 9, 8, 12, 0),
|
|
||||||
updated_at=None,
|
|
||||||
category=None,
|
|
||||||
author=None,
|
|
||||||
content_html="<p>Contenu</p>",
|
|
||||||
content_text="Contenu",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
etag="etag-after-delivery",
|
|
||||||
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
|
|
||||||
)
|
|
||||||
|
|
||||||
channel: Any
|
|
||||||
if channel_kind == "success":
|
|
||||||
channel = StubChannel(calls)
|
|
||||||
elif channel_kind == "false":
|
|
||||||
channel = FailingChannel()
|
|
||||||
elif channel_kind == "exception":
|
|
||||||
channel = ExceptionalChannel()
|
|
||||||
else:
|
|
||||||
channel = None
|
|
||||||
|
|
||||||
runner = PipelineRunner(
|
|
||||||
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
|
|
||||||
pronote_fetcher=StubFetcher(calls, lesson, homework),
|
|
||||||
caldav_synchronizer=lambda data, settings: successful_sync_result(),
|
|
||||||
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
|
|
||||||
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
|
|
||||||
blog_state=BlogRSSState(state_file),
|
|
||||||
channel=channel,
|
|
||||||
dry_run=dry_run,
|
|
||||||
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
data, errors = runner.run()
|
|
||||||
|
|
||||||
assert data is not None
|
|
||||||
if should_acknowledge:
|
|
||||||
acknowledged_state = BlogRSSState(state_file)
|
|
||||||
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
|
|
||||||
assert acknowledged_state.get_cache_headers() == (
|
|
||||||
"etag-after-delivery",
|
|
||||||
"Tue, 08 Sep 2026 12:00:00 GMT",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
assert not state_file.exists()
|
|
||||||
if channel_kind in {"false", "exception"}:
|
|
||||||
assert any(error.step == "send" for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_runner_secret_redaction_in_pipeline_errors(
|
def test_runner_secret_redaction_in_pipeline_errors(
|
||||||
pipeline_inputs: tuple[Lesson, Homework],
|
pipeline_inputs: tuple[Lesson, Homework],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pronote_sync.models.blog import BlogArticle
|
|
||||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
|
|
||||||
|
|
||||||
@@ -119,38 +116,6 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
|
|||||||
assert state_file.read_text(encoding="utf-8") == original_content
|
assert state_file.read_text(encoding="utf-8") == original_content
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
|
|
||||||
"""Vérifie l'acquittement atomique après une livraison confirmée.
|
|
||||||
|
|
||||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state = BlogRSSState(state_file)
|
|
||||||
article = BlogArticle(
|
|
||||||
id="guid-1",
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
state.acknowledge(
|
|
||||||
BlogRSSFetchResult(
|
|
||||||
articles=(article,),
|
|
||||||
etag="etag-1",
|
|
||||||
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert state.get_known_guids() == frozenset({"guid-1"})
|
|
||||||
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
||||||
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
||||||
|
|
||||||
|
|||||||
@@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None:
|
|||||||
assert str(event.get("UID")) == "homework-HW-5678"
|
assert str(event.get("UID")) == "homework-HW-5678"
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_status() -> None:
|
def test_homework_to_vevent_is_transparent_without_task_status() -> None:
|
||||||
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
|
"""Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework()
|
homework = _make_homework()
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert str(event.get("STATUS")) == "NEEDS-ACTION"
|
assert event.get("STATUS") is None
|
||||||
|
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_categories() -> None:
|
def test_homework_to_vevent_categories() -> None:
|
||||||
@@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_dtstart_dtend() -> None:
|
def test_homework_to_vevent_dtstart_dtend() -> None:
|
||||||
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
|
"""Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework(due_on=date(2026, 1, 20))
|
homework = _make_homework(due_on=date(2026, 1, 20))
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
|
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
||||||
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
|
assert event.get("DTEND").dt == date(2026, 1, 21)
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_summary() -> None:
|
def test_homework_to_vevent_summary() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user