"""Tests for channel-renderer integration (Phase 2.5b).""" import asyncio import time from unittest.mock import MagicMock, AsyncMock, patch import pytest from meshai.notifications.events import NotificationPayload from meshai.notifications.channels import ( MeshBroadcastChannel, MeshDMChannel, EmailChannel, WebhookChannel, ) # ============================================================ # MESH CHANNEL RENDERING TESTS # ============================================================ def test_mesh_channel_uses_mesh_renderer(): """MeshBroadcastChannel renders long messages to multiple chunks.""" mock_connector = MagicMock() channel = MeshBroadcastChannel( connector=mock_connector, channel_index=0, ) # Build a long message that will require chunking long_message = "This is a very long alert message that exceeds the character limit. " * 5 payload = NotificationPayload( message=long_message, category="weather_warning", severity="priority", timestamp=time.time(), event_type="weather_warning", ) asyncio.run(channel.deliver(payload, None)) # Should have called send_message multiple times (once per chunk) assert mock_connector.send_message.call_count >= 2 # Each call's text should be <= 200 chars for call in mock_connector.send_message.call_args_list: text = call.kwargs.get("text", call.args[0] if call.args else "") assert len(text) <= 200 def test_mesh_channel_uses_payload_message_directly_when_chunk_metadata_set(): """Pre-chunked payloads (from digest) skip re-rendering.""" mock_connector = MagicMock() channel = MeshBroadcastChannel( connector=mock_connector, channel_index=0, ) # Payload with chunk metadata set (from digest scheduler) payload = NotificationPayload( message="pre-chunked text", category="digest", severity="routine", timestamp=time.time(), chunk_index=1, chunk_total=3, ) asyncio.run(channel.deliver(payload, None)) # Should have called send_message exactly once assert mock_connector.send_message.call_count == 1 # Should use the message directly call = mock_connector.send_message.call_args text = call.kwargs.get("text", call.args[0] if call.args else "") assert text == "pre-chunked text" def test_mesh_dm_channel_uses_mesh_renderer(): """MeshDMChannel renders long messages to chunks for each recipient.""" mock_connector = MagicMock() channel = MeshDMChannel( connector=mock_connector, node_ids=["!node1", "!node2"], ) long_message = "This is a long DM message that should be chunked. " * 4 payload = NotificationPayload( message=long_message, category="test", severity="routine", timestamp=time.time(), ) asyncio.run(channel.deliver(payload, None)) # Should have called send_message multiple times # (chunks * nodes) assert mock_connector.send_message.call_count >= 2 def test_mesh_dm_channel_uses_payload_message_directly_when_chunk_metadata_set(): """Pre-chunked DM payloads skip re-rendering.""" mock_connector = MagicMock() channel = MeshDMChannel( connector=mock_connector, node_ids=["!node1"], ) payload = NotificationPayload( message="pre-chunked DM", category="digest", severity="routine", timestamp=time.time(), chunk_index=2, chunk_total=5, ) asyncio.run(channel.deliver(payload, None)) # Should use message directly, once per node assert mock_connector.send_message.call_count == 1 call = mock_connector.send_message.call_args text = call.kwargs.get("text", call.args[0] if call.args else "") assert text == "pre-chunked DM" # ============================================================ # EMAIL CHANNEL RENDERING TESTS # ============================================================ def test_email_channel_uses_email_renderer(): """EmailChannel uses renderer for subject and body.""" channel = EmailChannel( smtp_host="localhost", smtp_port=25, smtp_user="", smtp_password="", smtp_tls=False, from_address="test@example.com", recipients=["user@example.com"], ) payload = NotificationPayload( message="Test alert message", category="weather_warning", severity="immediate", timestamp=time.time(), event_type="weather_warning", ) # Mock the _send_email method with patch.object(channel, "_send_email") as mock_send: asyncio.run(channel.deliver(payload, None)) # Should have been called with renderer output mock_send.assert_called_once() call_args = mock_send.call_args subject = call_args.args[0] body = call_args.args[1] # Renderer format checks assert "[MeshAI]" in subject assert "IMMEDIATE" in subject assert "Test alert message" in body assert "Severity:" in body # ============================================================ # WEBHOOK CHANNEL RENDERING TESTS # ============================================================ def test_webhook_channel_uses_webhook_renderer(): """WebhookChannel uses renderer for JSON payload.""" channel = WebhookChannel( url="https://example.com/webhook", headers={}, ) payload = NotificationPayload( message="Test webhook message", category="test", severity="priority", timestamp=time.time(), event_type="battery_warning", ) # Mock httpx with patch("meshai.notifications.channels.httpx.AsyncClient") as mock_client_class: mock_client = MagicMock() mock_response = MagicMock() mock_response.status_code = 200 mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=None) mock_client_class.return_value = mock_client asyncio.run(channel.deliver(payload, None)) # Check the POST was called mock_client.post.assert_called_once() call_kwargs = mock_client.post.call_args.kwargs # Should have JSON payload with schema_version json_payload = call_kwargs.get("json", {}) assert "schema_version" in json_payload assert json_payload["schema_version"] == "1.0" assert json_payload["message"] == "Test webhook message" # ============================================================ # PER-FAMILY MESHCORE ROUTING — end-to-end threading guard # Updated for the explicit-per-mesh model (meshcore_broadcast/mesh_broadcast) # ============================================================ def test_mesh_broadcast_routes_to_meshtastic_only(): """mesh_broadcast passes transport='meshtastic' and channel index to send_message. meshcore_channel is NOT passed (auto-fan removed). Regression guard: before this model, mesh_broadcast also threaded meshcore_channel through; now it is Meshtastic-only. """ from meshai.config import NotificationRuleConfig from meshai.notifications.channels import create_channel mock_connector = MagicMock() rule = NotificationRuleConfig( name="toggle:fire", delivery_type="mesh_broadcast", broadcast_channel=1, meshcore_channel="AIDA", # present in config but must NOT flow to send_message ) channel = create_channel(rule, mock_connector) # Pre-chunked payload => exactly one deterministic send_message call. payload = NotificationPayload( message="fire alert", category="fire", severity="immediate", timestamp=time.time(), event_type="fire", chunk_index=0, ) assert asyncio.run(channel.deliver(payload, rule)) is True mock_connector.send_message.assert_called_once() kwargs = mock_connector.send_message.call_args.kwargs assert kwargs.get("channel") == 1 assert kwargs.get("transport") == "meshtastic" # meshcore_channel must NOT be present (no auto-fan). assert "meshcore_channel" not in kwargs or kwargs.get("meshcore_channel") is None def test_meshcore_broadcast_routes_to_meshcore_only(): """meshcore_broadcast passes meshcore_channel=name and transport='meshcore' to send_message. This is the explicit MeshCore-only delivery path.""" from meshai.config import NotificationRuleConfig from meshai.notifications.channels import MeshCoreBroadcastChannel, create_channel # Simulate a CompositeTransport connector with a meshcore child. mock_connector = MagicMock() mock_connector._by_name = {"meshcore": MagicMock(), "meshtastic": MagicMock()} mock_connector.send_message.return_value = True rule = NotificationRuleConfig( name="toggle:fire", delivery_type="meshcore_broadcast", meshcore_channel="AIDA", ) channel = create_channel(rule, mock_connector) assert isinstance(channel, MeshCoreBroadcastChannel) payload = NotificationPayload( message="fire alert", category="fire", severity="immediate", timestamp=time.time(), event_type="fire", chunk_index=0, ) assert asyncio.run(channel.deliver(payload, rule)) is True mock_connector.send_message.assert_called_once() kwargs = mock_connector.send_message.call_args.kwargs assert kwargs.get("meshcore_channel") == "AIDA" assert kwargs.get("transport") == "meshcore" assert kwargs.get("destination") is None def test_broadcast_render_loop_threads_meshcore_channel(): """Non-prechunked path (renderer loop) for meshcore_broadcast threads meshcore_channel on every chunk send.""" from meshai.config import NotificationRuleConfig from meshai.notifications.channels import create_channel mock_connector = MagicMock() # Connector has a meshcore child so the no-op guard passes. mock_connector._by_name = {"meshcore": MagicMock(), "meshtastic": MagicMock()} mock_connector.send_message.return_value = True rule = NotificationRuleConfig( name="toggle:fire", delivery_type="meshcore_broadcast", meshcore_channel="AIDA", ) channel = create_channel(rule, mock_connector) long_message = "This is a very long alert message that exceeds the limit. " * 5 payload = NotificationPayload( message=long_message, category="fire", severity="immediate", timestamp=time.time(), event_type="fire", ) assert asyncio.run(channel.deliver(payload, rule)) is True assert mock_connector.send_message.call_count >= 2 for call in mock_connector.send_message.call_args_list: assert call.kwargs.get("meshcore_channel") == "AIDA" assert call.kwargs.get("transport") == "meshcore"