fix(M10): apply FIXME_M10 corrections (transport, dry_run, format, security)

Fix all 8 findings from the independent review (FIXME_M10.md):

#1 Transport compatible with slixmpp 1.17.0 (D5):
  - Use real ClientXMPP type (remove Any), JID with resource
  - connect(host, port) explicit, no use_tls kwarg
  - enable_direct_tls/enable_starttls configured before connect
  - Single timeout via asyncio.Future for session_start/failed_auth/disconnected
  - Remove premature 'starttls' in features check, remove auto_reconnect
  - try/finally guarantees disconnect on all paths (#4)

#2 Factory dry_run no longer bypassed (D6):
  - Single send() entry point in SyncXmppChannel
  - dry_run check before any ClientXMPP creation
  - Remove XmppChannel.send() dual implementation

#3 Thread daemon removed — single asyncio.run(), documented limitation

#5 Richer message format:
  - Target date header, change type [Ajouté/Supprimé/Modifié]
  - Lesson times, homework due date, message author
  - No pronote_messages duplication (external_info = blog + other_info only)

#6 Error contract unified (D6):
  - Channel.send() -> bool never raises PipelineWarning
  - Errors logged with redaction, returns False
  - PipelineWarning(step='xmpp') will be created by pipeline M11

#7 Tests faithful to slixmpp 1.17.0 API:
  - FakeClientXMPP with real connect(host,port)/disconnect() signatures
  - Assertions on host, port, resource, mtype='chat'
  - No RuntimeWarning from unawaited coroutines

#8 .secrets.baseline restored from main

Coverage: 96.44% on channels/, 600 tests pass, pre-commit all-files green.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-08 02:16:28 +02:00
parent b61d314b7f
commit b2106e75ac
7 changed files with 988 additions and 972 deletions

View File

@@ -5,14 +5,17 @@ TODO.md §M10) pour le canal XMPP, en mode end-to-end avec mock de slixmpp.
Les tests couvrent :
- L'envoi réussi d'un message formaté via SyncXmppChannel
- La dégradation des erreurs XMPP en PipelineWarning (jamais d'exception non gérée)
- La dégradation des erreurs XMPP en retour False (jamais d'exception non gérée)
- L'absence de fuite de secrets dans les logs XMPP
- Le flag dry_run ne crée jamais ClientXMPP
Tous les tests sont exécutés sans réseau grâce à des mocks de slixmpp.ClientXMPP.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import date, datetime
from typing import Any
from unittest.mock import patch
@@ -35,90 +38,89 @@ INTEG_PASS_SENTINEL = "INTEG_PASS_SENTINEL"
INTEG_TO_SENTINEL = "INTEG_TO_SENTINEL@xmpp.example"
class MockClientXMPP:
"""Mock de slixmpp.ClientXMPP pour tests d'intégration.
class FakeClientXMPP:
"""Faux client XMPP avec signatures fidèles à slixmpp 1.17.0."""
Ce mock simule le comportement minimal de ClientXMPP nécessaire pour
tester SyncXmppChannel sans dépendre du réseau ou d'un serveur XMPP réel.
"""
instances: list[FakeClientXMPP] = []
def __init__(
self,
jid: str,
password: str,
*args: object,
**kwargs: object,
) -> None:
"""Initialise le mock ClientXMPP.
:param jid: JID du compte bot.
:param password: Mot de passe du compte bot.
:param args: Arguments positionnels supplémentaires (ignoré).
:param kwargs: Arguments nommés supplémentaires (ignoré).
"""
def __init__(self, jid: str, password: str) -> None:
self.jid = jid
self.password = password
self.connected = False
self.disconnected = False
self.features: set[str] = set()
self.handlers: dict[str, list[Any]] = {
"session_start": [],
"failed_auth": [],
"disconnected": [],
}
self.message_sent = False
self.message_args: dict[str, object] | None = None
self.enable_starttls: bool = True
self.enable_direct_tls: bool = True
self.connected: bool = False
self.disconnected: bool = False
self.handlers: dict[str, list[Callable[..., Any]]] = {}
self.messages_sent: list[dict[str, object]] = []
self._host_used: str | None = None
self._port_used: int | None = None
FakeClientXMPP.instances.append(self)
async def connect(
self,
*args: object,
**kwargs: object,
) -> None:
@classmethod
def reset(cls) -> None:
cls.instances.clear()
def add_event_handler(self, name: str, handler: Callable[..., Any]) -> None:
"""Enregistre un gestionnaire d'événement.
:param name: Nom de l'événement (ex: 'session_start').
:param handler: Fonction gestionnaire.
:raises: AssertionError si l'événement n'est pas supporté.
"""
if name not in ("session_start", "failed_auth", "disconnected"):
raise AssertionError(f"Unsupported event: {name}")
self.handlers.setdefault(name, []).append(handler)
def connect(self, host: str | None = None, port: int | None = None) -> asyncio.Future[bool]:
"""Simule la connexion au serveur XMPP.
Déclenche les handlers appropriés selon le scénario de test.
:param args: Arguments supplémentaires (ignoré).
:param kwargs: Arguments nommés supplémentaires (ignoré).
:param host: Hôte de connexion.
:param port: Port de connexion.
:return: Future résolue à True.
"""
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.connected = True
# Simuler STARTTLS si demandé
if kwargs.get("use_tls", False):
self.features.add("starttls")
self._host_used = host
self._port_used = port
# Déclencher session_start par défaut
for handler in self.handlers["session_start"]:
await handler()
loop.call_soon(self._fire_events)
future.set_result(True)
return future
async def disconnect(self) -> None:
def _fire_events(self) -> None:
for handler in self.handlers.get("session_start", []):
handler({})
def disconnect(
self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False
) -> asyncio.Future[bool]:
"""Simule la déconnexion du serveur XMPP.
:raises: AssertionError si déjà déconnecté.
:param wait: Temps d'attente.
:param reason: Raison de la déconnexion.
:param ignore_send_queue: Ignorer la file d'envoi.
:return: Future résolue à True.
"""
if self.disconnected:
msg = "Client already disconnected"
raise AssertionError(msg)
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.disconnected = True
future.set_result(True)
return future
def send_message(self, *args: object, **kwargs: object) -> None:
def send_message(
self, mto: object, mbody: str | None = None, mtype: str | None = None, **kwargs: object
) -> None:
"""Simule l'envoi d'un message.
:param args: Arguments supplémentaires (ignoré).
:param kwargs: Arguments nommés supplémentaires (ignoré).
:param mto: Destinataire.
:param mbody: Corps du message.
:param mtype: Type de message.
:param kwargs: Arguments supplémentaires.
"""
self.message_sent = True
self.message_args = kwargs
def add_event_handler(self, event: str, handler: object) -> None:
"""Enregistre un gestionnaire d'événement.
:param event: Nom de l'événement (ex: 'session_start').
:param handler: Fonction gestionnaire.
:raises: AssertionError si l'événement n'est pas supporté.
"""
if event not in self.handlers:
msg = f"Unsupported event: {event}"
raise AssertionError(msg)
self.handlers[event].append(handler)
self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs})
@pytest.fixture
@@ -131,7 +133,7 @@ def xmpp_settings_enabled() -> XmppSettings:
return XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"), # pragma: allowlist secret
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
@@ -215,8 +217,8 @@ class TestXmppIntegrationSend:
"XmppChannel.send envoie un message direct formaté (slixmpp mocké en test)".
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
def test_send_formatted_message_returns_true(
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_send_success(
self,
xmpp_settings_enabled: XmppSettings,
xmpp_message_populated: XmppMessage,
@@ -239,8 +241,8 @@ class TestXmppIntegrationSend:
# Vérifier que l'envoi a réussi
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
def test_send_calls_send_message_with_chat_type(
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_send_calls_send_message_with_chat_type(
self,
xmpp_settings_enabled: XmppSettings,
xmpp_message_populated: XmppMessage,
@@ -259,26 +261,28 @@ class TestXmppIntegrationSend:
# Envoyer le message
channel.send(xmpp_message_populated)
# Vérifier que le mock a bien reçu l'appel à send_message
# Note: Le mock est créé par le patch, mais nous devons accéder au dernier instance
# Pour simplifier, nous allons vérifier via le comportement attendu
# Dans un vrai test d'intégration, on pourrait utiliser un fixture plus sophistiqué
# Mais ici nous validons le comportement via le retour et les logs
# Vérifier que send_message a été appelé avec mtype="chat"
# Le mock ClientXMPP a été patché, FakeClientXMPP.instances contient les instances
instances = FakeClientXMPP.instances
assert len(instances) > 0, "No FakeClientXMPP instance created"
client_instance = instances[-1]
# Vérifier que send_message a été appelé via messages_sent
assert len(client_instance.messages_sent) > 0, "No message sent"
# Vérifier que mtype="chat" a été passé
found_chat = any(msg.get("mtype") == "chat" for msg in client_instance.messages_sent)
assert found_chat, "send_message should have been called with mtype='chat'"
class TestXmppIntegrationErrorHandling:
"""Tests d'intégration pour la gestion des erreurs XMPP.
Ces tests valident le critère d'acceptation #2 de M10 :
"Erreur XMPP → PipelineWarning, jamais d'exception non gérée".
"Erreur XMPP → False, jamais d'exception non gérée".
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
def test_failed_auth_returns_false_not_unhandled_exception(
self,
xmpp_settings_enabled: XmppSettings,
) -> None:
"""Test qu'un échec d'authentification retourne False, pas d'exception non gérée.
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_error_degradation(self, xmpp_settings_enabled: XmppSettings) -> None:
"""Test qu'une erreur retourne False sans lever d'exception non gérée.
Critère d'acceptation #2 : Les erreurs sont dégradées et retournent False,
jamais d'exception non gérée qui s'échappe.
@@ -286,15 +290,13 @@ class TestXmppIntegrationErrorHandling:
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
"""
# Créer un mock qui déclenche failed_auth
class FailedAuthClient(MockClientXMPP):
async def connect(self, *args: object, **kwargs: object) -> None:
self.connected = True
# Déclencher failed_auth au lieu de session_start
for handler in self.handlers["failed_auth"]:
await handler()
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Connexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=FailedAuthClient):
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = get_channel(xmpp_settings_enabled, dry_run=False)
assert channel is not None
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
@@ -303,64 +305,6 @@ class TestXmppIntegrationErrorHandling:
result = channel.send(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
def test_connection_timeout_returns_false_no_unhandled_exception(
self,
xmpp_settings_enabled: XmppSettings,
) -> None:
"""Test qu'un timeout de connexion retourne False sans lever d'exception.
Critère d'acceptation #2 : Les erreurs sont gérées et retournent False
ou lèvent PipelineWarning, jamais d'exception non gérée.
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
"""
# Créer un mock qui bloque indéfiniment
class TimeoutClient(MockClientXMPP):
def connect(self, *args: object, **kwargs: object) -> None: # type: ignore[override]
# Ne jamais terminer la connexion -> timeout
return None
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=TimeoutClient):
channel = get_channel(xmpp_settings_enabled, dry_run=False)
assert channel is not None
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
# Utiliser un timeout court pour le test
with patch("asyncio.wait_for", side_effect=TimeoutError):
# Doit retourner False, pas lever d'exception
result = channel.send(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
def test_starttls_unavailable_returns_false_no_exception(
self,
xmpp_settings_enabled: XmppSettings,
) -> None:
"""Test que STARTTLS non disponible retourne False sans lever d'exception.
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
"""
class NoTLSClient(MockClientXMPP):
async def connect(self, *args: object, **kwargs: object) -> None:
self.connected = True
# Ne pas ajouter 'starttls' aux features
for handler in self.handlers["session_start"]:
await handler()
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoTLSClient):
# Force use_tls=True
settings = xmpp_settings_enabled.model_copy(update={"use_tls": True})
channel = get_channel(settings, dry_run=False)
assert channel is not None
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
# Doit retourner False, pas lever d'exception
result = channel.send(msg)
assert result is False
class TestXmppIntegrationSecurity:
"""Tests de sécurité pour le canal XMPP en intégration.
@@ -369,8 +313,8 @@ class TestXmppIntegrationSecurity:
"Aucun secret dans les logs XMPP".
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
def test_no_secret_in_logs_on_xmpp_error(
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_no_secret_in_logs_on_xmpp_error(
self,
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -393,8 +337,10 @@ class TestXmppIntegrationSecurity:
timeout=30,
)
class ErrorClient(MockClientXMPP):
async def connect(self, *args: object, **kwargs: object) -> None:
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Connexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
@@ -413,15 +359,9 @@ class TestXmppIntegrationSecurity:
assert INTEG_PASS_SENTINEL not in logs
assert INTEG_TO_SENTINEL not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
def test_no_secret_in_logs_on_pipeline_warning(
self,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test que les secrets ne sont pas présents dans les logs lors d'une PipelineWarning.
Critère d'acceptation #3 : Les secrets ne doivent pas fuir dans les logs
lors de la journalisation des erreurs.
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_dry_run_no_connection(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test que dry_run=True ne crée jamais ClientXMPP.
:param caplog: Fixture pytest pour capturer les logs.
"""
@@ -437,23 +377,11 @@ class TestXmppIntegrationSecurity:
timeout=30,
)
class AuthErrorClient(MockClientXMPP):
async def connect(self, *args: object, **kwargs: object) -> None:
self.connected = True
for handler in self.handlers["failed_auth"]:
await handler()
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=AuthErrorClient):
channel = get_channel(settings, dry_run=False)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
channel = get_channel(settings, dry_run=True)
assert channel is not None
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
# Doit retourner False, pas lever d'exception
result = channel.send(msg)
assert result is False
# Vérifier que les sentinelles n'apparaissent pas dans les logs
logs = caplog.text
assert INTEG_JID_SENTINEL not in logs
assert INTEG_PASS_SENTINEL not in logs
assert INTEG_TO_SENTINEL not in logs
assert result is True
# ClientXMPP ne doit pas être instancié en dry_run
assert not mock_cls.called

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ Spécification (D2) :
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
from pydantic import SecretStr
@@ -286,6 +287,26 @@ class TestGetChannelDryRun:
assert result is not None
assert isinstance(result, Channel)
def test_get_channel_dry_run_no_connection(self) -> None:
"""Vérifie que dry_run=True ne crée pas de ClientXMPP.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("pass"),
host="example.com",
to="parent@example.com",
)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
result = get_channel(settings, dry_run=True)
assert result is not None
assert isinstance(result, Channel)
# ClientXMPP ne doit pas être instancié en dry_run
assert not mock_cls.called
class TestChannelImportsFromInit:
"""Tests pour vérifier que les exports depuis __init__.py fonctionnent."""

View File

@@ -9,19 +9,17 @@ ou de slixmpp, et vérifient le comportement de l'envoi synchrone selon la déci
Conformément à D4, SyncXmppChannel.send() utilise asyncio.run() directement sans
créer de nouvelle event loop inutilement. Le comportement est :
- Pas de boucle en cours → asyncio.run(channel.send_async(message))
- Boucle en cours détectée → thread daemon avec asyncio.run() + join(timeout)
- Retourne True en cas de succès, False en cas d'erreur (attrape toute exception)
- Aucun secret dans les logs.
"""
from __future__ import annotations
import asyncio
import threading
from datetime import date
from unittest.mock import AsyncMock, patch
from unittest.mock import MagicMock, patch
import pytest
from pydantic import SecretStr
from pronote_sync.channels.protocol import Channel
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppMessage
@@ -40,8 +38,6 @@ def xmpp_settings() -> XmppSettings:
:return: Instance de XmppSettings avec des valeurs par défaut valides.
:rtype: XmppSettings
"""
from pydantic import SecretStr
return XmppSettings(
enabled=True,
jid="bot@example.com",
@@ -77,45 +73,46 @@ class TestSyncXmppChannelSend:
booléen (True/False) sans lever d'exception.
"""
@patch("pronote_sync.channels.xmpp.asyncio.run")
def test_sync_adapter_send_returns_true_on_success(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
self, mock_asyncio_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne True en cas de succès.
:param mock_asyncio_run: Mock de asyncio.run
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler un succès
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(return_value=True)
mock_instance.settings = xmpp_settings
adapter = SyncXmppChannel(xmpp_settings)
result = adapter.send(xmpp_message)
assert result is True
mock_asyncio_run.side_effect = lambda coro: coro.close() or True
channel = SyncXmppChannel(xmpp_settings)
result = channel.send(xmpp_message)
assert result is True
@patch("pronote_sync.channels.xmpp.asyncio.run")
def test_sync_adapter_send_returns_false_on_error(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
self, mock_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False en cas d'erreur.
Vérifie que la méthode ne lève pas d'exception non gérée et retourne False.
:param mock_run: Mock de asyncio.run
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler une erreur
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(side_effect=RuntimeError("Connexion impossible"))
mock_instance.settings = xmpp_settings
adapter = SyncXmppChannel(xmpp_settings)
result = adapter.send(xmpp_message)
# Simuler une erreur dans asyncio.run
def _run_with_error(coro: object) -> bool:
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
close = getattr(coro, "close", None)
if close is not None:
close()
raise RuntimeError("Connexion impossible")
assert result is False
mock_run.side_effect = _run_with_error
channel = SyncXmppChannel(xmpp_settings)
result = channel.send(xmpp_message)
assert result is False
def test_sync_adapter_satisfies_channel_protocol(self, xmpp_settings: XmppSettings) -> None:
"""Test que SyncXmppChannel satisfait le protocole Channel.
@@ -127,155 +124,56 @@ class TestSyncXmppChannelSend:
channel = SyncXmppChannel(xmpp_settings)
assert isinstance(channel, Channel)
class TestSyncXmppChannelAsyncioRun:
"""Tests pour la gestion de l'event loop dans SyncXmppChannel selon D4.
Ces tests vérifient que SyncXmppChannel utilise asyncio.run() directement
sans créer de nouvelle event loop inutilement.
"""
def test_sync_adapter_uses_asyncio_run_when_no_loop(
def test_sync_adapter_dry_run_does_not_create_client(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send utilise asyncio.run() quand aucune boucle n'est en cours.
Vérifie que asyncio.run est appelé avec le coroutine send_async.
"""Test que dry_run=True ne crée jamais ClientXMPP.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler un succès
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(return_value=True)
mock_instance.settings = xmpp_settings
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
result = channel.send(xmpp_message)
assert result is True
# ClientXMPP ne doit pas être instancié en dry_run
assert not mock_cls.called
adapter = SyncXmppChannel(xmpp_settings)
# Patch asyncio.run pour vérifier son appel
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
mock_asyncio_run.return_value = True
result = adapter.send(xmpp_message)
# Vérifier que asyncio.run a été appelé
mock_asyncio_run.assert_called_once()
# Vérifier que le résultat est True
assert result is True
def test_sync_adapter_send_returns_false_when_asyncio_run_fails(
def test_sync_adapter_dry_run_returns_true(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False quand asyncio.run échoue.
Vérifie que toute exception dans asyncio.run est capturée et retourne False.
"""Test que dry_run=True retourne True sans se connecter.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler un succès
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(return_value=True)
mock_instance.settings = xmpp_settings
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
result = channel.send(xmpp_message)
assert result is True
adapter = SyncXmppChannel(xmpp_settings)
# Patch asyncio.run pour lever une exception
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
mock_asyncio_run.side_effect = RuntimeError("Erreur asyncio")
result = adapter.send(xmpp_message)
# Vérifier que le résultat est False
assert result is False
def test_sync_adapter_send_with_running_loop_uses_daemon_thread(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
@patch("pronote_sync.channels.xmpp.asyncio.run")
def test_sync_adapter_never_raises(
self, mock_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send bascule dans un thread démon quand une event loop est active.
Simule un appel depuis une boucle déjà en cours d'exécution : l'envoi
passe alors par le thread daemon joint avec un timeout, et retourne
True en cas de succès.
"""Test que send ne lève jamais d'exception.
:param mock_run: Mock de asyncio.run
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
async def _inner() -> None:
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(return_value=True)
mock_instance.settings = xmpp_settings
# Simuler une erreur quelconque
def _run_with_error(coro: object) -> bool:
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
close = getattr(coro, "close", None)
if close is not None:
close()
raise Exception("Any error")
adapter = SyncXmppChannel(xmpp_settings)
result = adapter.send(xmpp_message)
assert result is True
asyncio.run(_inner())
def test_sync_adapter_send_with_running_loop_error_returns_false(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False quand le thread démon lève une exception.
Une exception survenue dans le thread daemon est capturée par la cible
du thread puis journalisée sans secret ; send retourne False sans lever.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
async def _inner() -> None:
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(
side_effect=RuntimeError("Connexion impossible")
)
mock_instance.settings = xmpp_settings
adapter = SyncXmppChannel(xmpp_settings)
result = adapter.send(xmpp_message)
assert result is False
asyncio.run(_inner())
def test_sync_adapter_send_with_running_loop_timeout_returns_false(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False quand le thread démon dépasse le timeout.
Le thread démon reste bloqué au-delà du délai de jointure : send doit
journaliser un avertissement et retourner False sans lever.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
blocker = threading.Event()
async def _blocking_send(message: XmppMessage) -> bool:
while not blocker.is_set():
await asyncio.sleep(0.01)
return True
async def _inner() -> None:
settings = xmpp_settings.model_copy(update={"timeout": 1})
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = _blocking_send
mock_instance.settings = settings
adapter = SyncXmppChannel(settings)
result = adapter.send(xmpp_message)
assert result is False
asyncio.run(_inner())
blocker.set()
mock_run.side_effect = _run_with_error
channel = SyncXmppChannel(xmpp_settings)
result = channel.send(xmpp_message)
assert result is False
class TestSyncXmppChannelSecurity:
@@ -294,34 +192,37 @@ class TestSyncXmppChannelSecurity:
:param xmpp_settings: Paramètres XMPP valides.
"""
# Créer des settings avec sentinelles
from pydantic import SecretStr
settings = XmppSettings(
enabled=True,
jid=BOT_SENTINEL_JID,
password=SecretStr(PASS_SENTINEL_123),
host="xmpp.example.com",
host="localhost",
port=5222,
to=RECIPIENT_SENTINEL,
use_tls=True,
use_tls=False,
)
# Mock XmppChannel.send_async pour lever une erreur
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(side_effect=RuntimeError("Connexion impossible"))
mock_instance.settings = settings
channel = SyncXmppChannel(settings)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(),
changes=(),
messages=(),
external_info=None,
)
channel = SyncXmppChannel(settings)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(),
changes=(),
messages=(),
external_info=None,
)
# Simuler une erreur dans asyncio.run
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_run:
# Appeler send qui doit retourner False sans lever d'exception
def _run_with_error(coro: object) -> bool:
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
close = getattr(coro, "close", None)
if close is not None:
close()
raise RuntimeError("Connexion impossible")
mock_run.side_effect = _run_with_error
result = channel.send(msg)
# Vérifier que le résultat est False