mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
feat(notifications): Phase 2.5b per-channel-type renderers
Adds dedicated renderer classes per channel type:
- MeshRenderer produces 1+ chunks <=200 chars with (k/N) counters
when the payload overflows. Reuses the toggle-label vocabulary
from the digest. Mesh channels skip re-chunking when the payload
already carries chunk_index metadata (digest path).
- EmailRenderer produces {subject, body} with structured context
lines. Plain text only; HTML body is a future polish.
- WebhookRenderer produces a JSON-serializable dict with stable
schema_version 1.0. Optional fields omitted (not nulled) for
compactness. Designed for reuse by Phase 2.6.5's MQTT event
publisher.
- All four channel implementations (MeshBroadcast, MeshDM, Email,
Webhook) now call their renderer in deliver() before transport.
- New renderer tests cover each renderer in isolation; new channel
integration tests confirm channels actually call their renderer.
Renderers are pure functions of the payload - no network, no
state, fully testable without mocking I/O. The future MQTT
publisher will instantiate WebhookRenderer directly.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c9d9a9925c
commit
b2bb7f7a95
8 changed files with 898 additions and 30 deletions
214
tests/test_channel_rendering.py
Normal file
214
tests/test_channel_rendering.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""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"
|
||||
317
tests/test_renderers.py
Normal file
317
tests/test_renderers.py
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
"""Tests for Phase 2.5b per-channel-type renderers."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.notifications.events import NotificationPayload, make_event, make_payload_from_event
|
||||
from meshai.notifications.renderers import MeshRenderer, EmailRenderer, WebhookRenderer
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MESH RENDERER TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_mesh_render_short_message_single_chunk():
|
||||
"""Short message produces a single chunk."""
|
||||
payload = NotificationPayload(
|
||||
message="Test alert",
|
||||
category="test",
|
||||
severity="routine",
|
||||
timestamp=time.time(),
|
||||
)
|
||||
renderer = MeshRenderer()
|
||||
chunks = renderer.render(payload)
|
||||
assert len(chunks) == 1
|
||||
assert "Test alert" in chunks[0]
|
||||
|
||||
|
||||
def test_mesh_render_event_type_prefix():
|
||||
"""Known event type adds toggle label prefix."""
|
||||
payload = NotificationPayload(
|
||||
message="Severe storm",
|
||||
category="weather_warning",
|
||||
severity="priority",
|
||||
timestamp=time.time(),
|
||||
event_type="weather_warning",
|
||||
)
|
||||
renderer = MeshRenderer()
|
||||
chunks = renderer.render(payload)
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].startswith("[Weather]")
|
||||
|
||||
|
||||
def test_mesh_render_unknown_event_type_no_prefix():
|
||||
"""Unknown event type does not add a prefix."""
|
||||
payload = NotificationPayload(
|
||||
message="Hello",
|
||||
category="made_up_thing",
|
||||
severity="routine",
|
||||
timestamp=time.time(),
|
||||
event_type="made_up_thing",
|
||||
)
|
||||
renderer = MeshRenderer()
|
||||
chunks = renderer.render(payload)
|
||||
assert len(chunks) == 1
|
||||
assert not chunks[0].startswith("[")
|
||||
|
||||
|
||||
def test_mesh_render_long_message_chunks():
|
||||
"""Long message splits into multiple chunks with counters."""
|
||||
# Build a ~500 char message
|
||||
long_message = "This is a very long alert message that should definitely exceed the two hundred character limit. " * 5
|
||||
payload = NotificationPayload(
|
||||
message=long_message,
|
||||
category="weather_warning",
|
||||
severity="priority",
|
||||
timestamp=time.time(),
|
||||
event_type="weather_warning",
|
||||
)
|
||||
renderer = MeshRenderer()
|
||||
chunks = renderer.render(payload)
|
||||
|
||||
assert len(chunks) >= 2
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= 200, f"Chunk exceeds limit: {len(chunk)} chars"
|
||||
# Check each chunk ends with "(k/N)" counter
|
||||
for chunk in chunks:
|
||||
assert re.search(r"\(\d+/\d+\)$", chunk), f"Missing counter: {chunk}"
|
||||
|
||||
|
||||
def test_mesh_render_chunks_preserve_full_content():
|
||||
"""Chunking preserves all words from the original message."""
|
||||
# Build a message with distinct tokens
|
||||
words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
|
||||
"hotel", "india", "juliet", "kilo", "lima", "mike", "november",
|
||||
"oscar", "papa", "quebec", "romeo", "sierra", "tango", "uniform",
|
||||
"victor", "whiskey", "xray", "yankee", "zulu"]
|
||||
long_message = " ".join(words * 3) # Repeat to span multiple chunks
|
||||
|
||||
payload = NotificationPayload(
|
||||
message=long_message,
|
||||
category="test",
|
||||
severity="routine",
|
||||
timestamp=time.time(),
|
||||
)
|
||||
renderer = MeshRenderer()
|
||||
chunks = renderer.render(payload)
|
||||
|
||||
# Join all chunks and remove counters
|
||||
combined = " ".join(chunks)
|
||||
combined = re.sub(r"\s*\(\d+/\d+\)", "", combined)
|
||||
|
||||
# Every original word should appear
|
||||
for word in words:
|
||||
assert word in combined, f"Missing word: {word}"
|
||||
|
||||
|
||||
def test_mesh_render_no_event_type():
|
||||
"""Payload without event_type has no prefix."""
|
||||
payload = NotificationPayload(
|
||||
message="Plain message",
|
||||
category="test",
|
||||
severity="routine",
|
||||
timestamp=time.time(),
|
||||
event_type=None,
|
||||
)
|
||||
renderer = MeshRenderer()
|
||||
chunks = renderer.render(payload)
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0] == "Plain message"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EMAIL RENDERER TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_email_render_subject_includes_severity_and_type():
|
||||
"""Subject line includes severity and event type."""
|
||||
payload = NotificationPayload(
|
||||
message="Storm approaching",
|
||||
category="weather_warning",
|
||||
severity="immediate",
|
||||
timestamp=time.time(),
|
||||
event_type="weather_warning",
|
||||
)
|
||||
renderer = EmailRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
|
||||
assert "IMMEDIATE" in rendered["subject"]
|
||||
assert "Weather Warning" in rendered["subject"]
|
||||
|
||||
|
||||
def test_email_render_body_includes_message_and_context():
|
||||
"""Body includes message and structured context fields."""
|
||||
fixed_time = 1700000000.0 # Known timestamp
|
||||
payload = NotificationPayload(
|
||||
message="Test alert message",
|
||||
category="battery_warning",
|
||||
severity="priority",
|
||||
timestamp=fixed_time,
|
||||
event_type="battery_warning",
|
||||
region="Magic Valley",
|
||||
node_name="BLD-MTN",
|
||||
)
|
||||
renderer = EmailRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
body = rendered["body"]
|
||||
|
||||
assert "Test alert message" in body
|
||||
assert "Magic Valley" in body
|
||||
assert "BLD-MTN" in body
|
||||
assert "priority" in body
|
||||
assert "battery_warning" in body
|
||||
# Check formatted date
|
||||
assert "2023-11-14" in body # Date part of timestamp
|
||||
|
||||
|
||||
def test_email_render_omits_missing_context():
|
||||
"""Body omits lines for missing optional fields."""
|
||||
payload = NotificationPayload(
|
||||
message="Minimal alert",
|
||||
category="test",
|
||||
severity="routine",
|
||||
timestamp=time.time(),
|
||||
# No region, no node, no event_type
|
||||
)
|
||||
renderer = EmailRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
body = rendered["body"]
|
||||
|
||||
assert "Minimal alert" in body
|
||||
assert "Severity: routine" in body
|
||||
assert "Region:" not in body
|
||||
assert "Node:" not in body
|
||||
assert "Category:" not in body
|
||||
|
||||
|
||||
def test_email_render_includes_source_event():
|
||||
"""Body includes source event details when present."""
|
||||
event = make_event(
|
||||
source="weather_adapter",
|
||||
category="weather_warning",
|
||||
severity="priority",
|
||||
title="Severe Storm",
|
||||
summary="Severe storm expected",
|
||||
)
|
||||
payload = make_payload_from_event(event)
|
||||
|
||||
renderer = EmailRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
body = rendered["body"]
|
||||
|
||||
assert "weather_adapter" in body
|
||||
assert "Severe Storm" in body
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WEBHOOK RENDERER TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_webhook_render_has_schema_version():
|
||||
"""Output includes schema_version field."""
|
||||
payload = NotificationPayload(
|
||||
message="Test",
|
||||
category="test",
|
||||
severity="routine",
|
||||
timestamp=time.time(),
|
||||
)
|
||||
renderer = WebhookRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
|
||||
assert rendered["schema_version"] == "1.0"
|
||||
|
||||
|
||||
def test_webhook_render_omits_none_fields():
|
||||
"""None optional fields are omitted, not set to null."""
|
||||
payload = NotificationPayload(
|
||||
message="Test message",
|
||||
category="test",
|
||||
severity="routine",
|
||||
timestamp=time.time(),
|
||||
# All optional fields default to None
|
||||
)
|
||||
renderer = WebhookRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
|
||||
# Required fields present
|
||||
assert "message" in rendered
|
||||
assert "severity" in rendered
|
||||
assert "timestamp" in rendered
|
||||
assert "schema_version" in rendered
|
||||
|
||||
# Optional fields omitted
|
||||
assert "node_id" not in rendered
|
||||
assert "region" not in rendered
|
||||
assert "chunk_index" not in rendered
|
||||
|
||||
|
||||
def test_webhook_render_includes_source_event_when_present():
|
||||
"""source_event dict included when payload has source event."""
|
||||
event = make_event(
|
||||
source="test_adapter",
|
||||
category="test",
|
||||
severity="routine",
|
||||
title="Test Event",
|
||||
)
|
||||
payload = make_payload_from_event(event)
|
||||
|
||||
renderer = WebhookRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
|
||||
assert "source_event" in rendered
|
||||
assert rendered["source_event"]["id"] == event.id
|
||||
assert rendered["source_event"]["source"] == "test_adapter"
|
||||
|
||||
|
||||
def test_webhook_render_is_json_serializable():
|
||||
"""Rendered output is JSON-serializable (no datetime objects)."""
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test",
|
||||
severity="routine",
|
||||
title="Test",
|
||||
)
|
||||
payload = make_payload_from_event(event)
|
||||
payload.chunk_index = 1
|
||||
payload.chunk_total = 3
|
||||
payload.region = "Test Region"
|
||||
payload.node_name = "Test-Node"
|
||||
|
||||
renderer = WebhookRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
|
||||
# Should not raise
|
||||
json_str = json.dumps(rendered)
|
||||
assert isinstance(json_str, str)
|
||||
# Verify round-trip
|
||||
parsed = json.loads(json_str)
|
||||
assert parsed["message"] == payload.message
|
||||
|
||||
|
||||
def test_webhook_render_includes_optional_fields_when_set():
|
||||
"""Optional fields included when they have values."""
|
||||
payload = NotificationPayload(
|
||||
message="Test",
|
||||
category="test_category",
|
||||
severity="priority",
|
||||
timestamp=time.time(),
|
||||
event_type="battery_warning",
|
||||
node_id="!abc123",
|
||||
node_name="Test-Node",
|
||||
region="Test Region",
|
||||
chunk_index=2,
|
||||
chunk_total=5,
|
||||
)
|
||||
renderer = WebhookRenderer()
|
||||
rendered = renderer.render(payload)
|
||||
|
||||
assert rendered["category"] == "test_category"
|
||||
assert rendered["event_type"] == "battery_warning"
|
||||
assert rendered["node_id"] == "!abc123"
|
||||
assert rendered["node_name"] == "Test-Node"
|
||||
assert rendered["region"] == "Test Region"
|
||||
assert rendered["chunk_index"] == 2
|
||||
assert rendered["chunk_total"] == 5
|
||||
Loading…
Add table
Add a link
Reference in a new issue