test: add M10 integration tests and coverage to 99% (M10-U7)
Integration tests validating the 3 M10 acceptance criteria end-to-end with mocked slixmpp: 1. XmppChannel.send sends formatted direct message 2. XMPP error → PipelineWarning, no unhandled exception 3. No secret in XMPP logs (sentinel-based verification) Additional unit tests covering daemon-thread branch of SyncXmppChannel, deferred failed_auth, session timeout, disconnected handler, and _format_message edge cases. Channels coverage: 99.18%. All M10 checklist items marked complete in TODO.md. Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -20,7 +20,7 @@ from pydantic import SecretStr
|
||||
from pronote_sync.channels.xmpp import XmppChannel, XmppMessage
|
||||
from pronote_sync.config.settings import XmppSettings
|
||||
from pronote_sync.errors import PipelineWarning
|
||||
from pronote_sync.models.agenda import Lesson
|
||||
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
|
||||
from pronote_sync.models.blog import BlogArticle, ExternalInfo
|
||||
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
|
||||
from pronote_sync.models.homework import Homework
|
||||
@@ -305,6 +305,61 @@ class TestXmppChannelFormatMessage:
|
||||
assert "💬 Messages\nAucun message." in formatted
|
||||
assert "📢 Informations diverses\nAucune information." in formatted
|
||||
|
||||
def test_format_message_with_theoretical_lesson(self) -> None:
|
||||
"""Test que la matière d'un changement REMOVED provient du cours théorique.
|
||||
|
||||
Couvre le cas où ``lesson`` est absent et où le sujet est extrait de
|
||||
``theoretical_lesson`` dans ``_format_changes``.
|
||||
"""
|
||||
theoretical = TheoreticalLesson(
|
||||
id="th1",
|
||||
day_of_week=0,
|
||||
start_time=time(8, 0),
|
||||
end_time=time(9, 0),
|
||||
subject="Anglais",
|
||||
)
|
||||
change = AgendaChange(
|
||||
type=AgendaChangeType.REMOVED,
|
||||
lesson=None,
|
||||
theoretical_lesson=theoretical,
|
||||
details="Cours annulé",
|
||||
)
|
||||
msg = XmppMessage(
|
||||
target_date=date(2025, 9, 7),
|
||||
synthesis=None,
|
||||
changes=(change,),
|
||||
external_info=None,
|
||||
)
|
||||
channel = XmppChannel(XmppSettings(), dry_run=True)
|
||||
formatted = channel._format_message(msg)
|
||||
assert "Anglais" in formatted
|
||||
assert "Cours annulé" in formatted
|
||||
|
||||
def test_format_message_with_message_content(self) -> None:
|
||||
"""Test que les messages Pronote sont formatés avec titre et contenu.
|
||||
|
||||
Couvre la branche non vide de ``_format_messages``.
|
||||
"""
|
||||
message = Message(
|
||||
id="msg1",
|
||||
type=MessageType.INFORMATION,
|
||||
title="Réunion parents-professeurs",
|
||||
content="Une réunion est organisée le 15/09 à 18h.",
|
||||
author="CPE",
|
||||
date=datetime.fromisoformat("2025-09-01T10:00:00"),
|
||||
read=False,
|
||||
)
|
||||
msg = XmppMessage(
|
||||
target_date=date(2025, 9, 7),
|
||||
synthesis=None,
|
||||
messages=(message,),
|
||||
external_info=None,
|
||||
)
|
||||
channel = XmppChannel(XmppSettings(), dry_run=True)
|
||||
formatted = channel._format_message(msg)
|
||||
assert "Réunion parents-professeurs" in formatted
|
||||
assert "Une réunion est organisée le 15/09 à 18h." in formatted
|
||||
|
||||
def test_format_message_html_sanitized(self) -> None:
|
||||
"""Test que le HTML est supprimé du contenu des devoirs et messages."""
|
||||
homework = Homework(
|
||||
@@ -456,6 +511,133 @@ class TestXmppChannelSend:
|
||||
result = channel.send(msg)
|
||||
assert result is False
|
||||
|
||||
def test_send_incomplete_config_raises_pipeline_warning(self) -> None:
|
||||
"""Test qu'une configuration XMPP incomplète lève un PipelineWarning.
|
||||
|
||||
Vérifie que le canal refuse l'envoi (sans tentative réseau) lorsque
|
||||
le JID, le mot de passe ou le destinataire sont absents.
|
||||
"""
|
||||
settings = XmppSettings(
|
||||
enabled=True,
|
||||
jid=None,
|
||||
password=None,
|
||||
to=None,
|
||||
host="xmpp.example.com",
|
||||
)
|
||||
channel = XmppChannel(settings, dry_run=False)
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
with pytest.raises(PipelineWarning):
|
||||
channel.send(msg)
|
||||
|
||||
def test_dry_run_without_secrets_logs_safely(self) -> None:
|
||||
"""Test que le dry-run fonctionne sans champs sensibles renseignés.
|
||||
|
||||
Vérifie que ``_secret_values`` gère des champs ``jid``, ``password``
|
||||
et ``to`` absents (branches ``None`` de la collecte des secrets).
|
||||
"""
|
||||
settings = XmppSettings(enabled=True, host="xmpp.example.com")
|
||||
channel = XmppChannel(settings, dry_run=True)
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
result = channel.send(msg)
|
||||
assert result is True
|
||||
|
||||
def test_send_disconnected_event_runs_handler(self, xmpp_settings: XmppSettings) -> None:
|
||||
"""Test que le handler ``disconnected`` est bien exécuté lors de l'envoi.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
"""
|
||||
|
||||
class DisconnectClient(MockClientXMPP):
|
||||
async def connect(self, *args: object, **kwargs: object) -> None:
|
||||
self.connected = True
|
||||
if kwargs.get("use_tls", False):
|
||||
self.features.add("starttls")
|
||||
for handler in self.handlers["disconnected"]:
|
||||
await handler()
|
||||
for handler in self.handlers["session_start"]:
|
||||
await handler()
|
||||
|
||||
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectClient):
|
||||
channel = XmppChannel(xmpp_settings, dry_run=False)
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
result = channel.send(msg)
|
||||
assert result is True
|
||||
|
||||
def test_send_failed_auth_after_connect_raises_pipeline_warning(
|
||||
self, xmpp_settings: XmppSettings
|
||||
) -> None:
|
||||
"""Test qu'un échec d'authentification après connect() lève un PipelineWarning.
|
||||
|
||||
Le client établit la session puis l'événement ``failed_auth`` survient
|
||||
avant la fin de l'attente de session : le canal doit se déconnecter
|
||||
proprement et lever un PipelineWarning.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
"""
|
||||
client_ref: list[MockClientXMPP] = []
|
||||
|
||||
def _factory(jid: str, password: str, *args: object, **kwargs: object) -> MockClientXMPP:
|
||||
instance = MockClientXMPP(jid, password, *args, **kwargs)
|
||||
client_ref.append(instance)
|
||||
return instance
|
||||
|
||||
async def _wait_with_failed_auth(
|
||||
fs: list[asyncio.Task[bool]],
|
||||
*,
|
||||
timeout: object = None,
|
||||
return_when: object = asyncio.FIRST_COMPLETED,
|
||||
) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]:
|
||||
# Déclenche failed_auth, puis nettoie les tâches de send_async.
|
||||
for handler in client_ref[0].handlers["failed_auth"]:
|
||||
await handler()
|
||||
for task in fs:
|
||||
task.cancel()
|
||||
await asyncio.gather(*fs, return_exceptions=True)
|
||||
return (set(), set())
|
||||
|
||||
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=_factory):
|
||||
channel = XmppChannel(xmpp_settings, dry_run=False)
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
with patch("asyncio.wait", new=_wait_with_failed_auth):
|
||||
with pytest.raises(PipelineWarning):
|
||||
channel.send(msg)
|
||||
|
||||
def test_send_session_timeout_raises_pipeline_warning(
|
||||
self, xmpp_settings: XmppSettings
|
||||
) -> None:
|
||||
"""Test qu'un délai d'attente de session dépassé lève un PipelineWarning.
|
||||
|
||||
Aucun événement n'établit la session : l'attente bornée expire et le
|
||||
canal se déconnecte proprement avant de lever un PipelineWarning.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
"""
|
||||
|
||||
class NoEventClient(MockClientXMPP):
|
||||
async def connect(self, *args: object, **kwargs: object) -> None:
|
||||
self.connected = True
|
||||
if kwargs.get("use_tls", False):
|
||||
self.features.add("starttls")
|
||||
|
||||
async def _empty_wait(
|
||||
fs: list[asyncio.Task[bool]],
|
||||
*,
|
||||
timeout: object = None,
|
||||
return_when: object = asyncio.FIRST_COMPLETED,
|
||||
) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]:
|
||||
# Aucun événement ne survient : on retourne une attente vide.
|
||||
for task in fs:
|
||||
task.cancel()
|
||||
await asyncio.gather(*fs, return_exceptions=True)
|
||||
return (set(), set())
|
||||
|
||||
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoEventClient):
|
||||
channel = XmppChannel(xmpp_settings, dry_run=False)
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
with patch("asyncio.wait", new=_empty_wait):
|
||||
with pytest.raises(PipelineWarning):
|
||||
channel.send(msg)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("xmpp_settings")
|
||||
class TestXmppChannelSecurity:
|
||||
|
||||
Reference in New Issue
Block a user