feat(notifications): Phase 2.5a channel interface unification

- Switch channels.py from dict-based to dataclass-based interfaces
- Add NotificationPayload dataclass and make_payload_from_event helper
- Update channel.deliver() to be async with (payload, rule) signature
- Add connector parameter to Dispatcher, DigestScheduler, and pipeline builders
- Update pipeline tee to use asyncio.create_task for async dispatch
- Add create_channel_from_dict for legacy router.py compatibility
- Update tests for new async interfaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-15 03:45:27 +00:00
commit c9d9a9925c
8 changed files with 235 additions and 129 deletions

View file

@ -60,8 +60,9 @@ class MockChannel:
def __init__(self):
self.deliveries = []
def deliver(self, payload: dict):
async def deliver(self, payload, rule=None):
self.deliveries.append(payload)
return True
class MockLLMBackend:
@ -93,7 +94,7 @@ def make_scheduler(
channels = {}
def channel_factory(rule):
def channel_factory(rule, connector=None):
ch = MockChannel()
channels[rule.name] = ch
return ch
@ -223,8 +224,8 @@ class TestFireBehavior:
ch = channels["digest-mesh"]
assert len(ch.deliveries) == 1
payload = ch.deliveries[0]
assert payload["category"] == "digest"
assert payload["severity"] == "routine"
assert payload.category == "digest"
assert payload.severity == "routine"
def test_fire_skips_disabled_rules(self):
"""Disabled rules are not delivered to."""
@ -293,8 +294,8 @@ class TestFireBehavior:
ch = channels["mesh"]
assert len(ch.deliveries) >= 1
for payload in ch.deliveries:
assert "chunk_index" in payload
assert "chunk_total" in payload
assert payload.chunk_index is not None
assert payload.chunk_total is not None
def test_fire_email_delivery_full_text(self):
"""Email delivery type gets single full-text delivery."""
@ -320,8 +321,8 @@ class TestFireBehavior:
ch = channels["email"]
assert len(ch.deliveries) == 1
payload = ch.deliveries[0]
assert "chunk_index" not in payload
assert "--- " in payload["message"]
assert payload.chunk_index is None
assert "--- " in payload.message
def test_fire_updates_last_fire_at(self):
"""_fire() updates last_fire_at timestamp."""
@ -350,7 +351,7 @@ class TestFireBehavior:
ch = channels["mesh"]
assert len(ch.deliveries) == 1
assert "No alerts" in ch.deliveries[0]["message"]
assert "No alerts" in ch.deliveries[0].message
# ---- Lifecycle Tests ----
@ -520,11 +521,11 @@ class TestIntegration:
call_order = []
def bad_channel_factory(rule):
def bad_channel_factory(rule, connector=None):
call_order.append(rule.name)
if rule.name == "bad":
ch = MagicMock()
ch.deliver.side_effect = RuntimeError("delivery failed")
ch.deliver = AsyncMock(side_effect=RuntimeError("delivery failed"))
return ch
return MockChannel()

View file

@ -8,8 +8,10 @@ Updated in Phase 2.4: Events now go to BOTH dispatcher and accumulator
compatibility but not used in production wiring.
"""
import asyncio
import pytest
from unittest.mock import Mock, patch
from unittest.mock import Mock, AsyncMock, patch
from dataclasses import dataclass, field
from meshai.notifications.events import Event, make_event
@ -55,15 +57,10 @@ class TestImmediateDispatch:
notifications=NotificationsConfigStub(rules=[rule])
)
mock_channel = Mock()
mock_channel.deliver = AsyncMock(return_value=True)
mock_factory = Mock(return_value=mock_channel)
bus = EventBus()
dispatcher = Dispatcher(config, mock_factory)
digest = StubDigestQueue()
router = SeverityRouter(
immediate_handler=dispatcher.dispatch,
digest_handler=digest.enqueue,
)
bus.subscribe(router.handle)
event = make_event(
source="test",
category="test_cat",
@ -71,12 +68,13 @@ class TestImmediateDispatch:
title="Test Alert",
summary="Test summary message",
)
bus.emit(event)
# Run dispatch in async context since it's now async
asyncio.run(dispatcher.dispatch(event))
assert mock_channel.deliver.call_count == 1
alert = mock_channel.deliver.call_args[0][0]
assert alert["category"] == "test_cat"
assert alert["severity"] == "immediate"
assert alert["message"]
assert alert.category == "test_cat"
assert alert.severity == "immediate"
assert alert.message
class TestTeeRouting:
@ -95,40 +93,29 @@ class TestTeeRouting:
notifications=NotificationsConfigStub(rules=[rule])
)
mock_channel = Mock()
mock_channel.deliver = AsyncMock(return_value=True)
mock_factory = Mock(return_value=mock_channel)
# Create dispatcher and track calls
# Create dispatcher
dispatcher = Dispatcher(config, mock_factory)
dispatch_calls = []
original_dispatch = dispatcher.dispatch
def tracking_dispatch(event):
dispatch_calls.append(event)
original_dispatch(event)
dispatcher.dispatch = tracking_dispatch
# Create accumulator mock
accumulator_calls = []
def mock_enqueue(event):
accumulator_calls.append(event)
# Tee closure (Phase 2.4 pattern)
def tee(event):
dispatcher.dispatch(event)
mock_enqueue(event)
bus = EventBus()
bus.subscribe(tee)
event = make_event(
source="test",
category="test_cat",
severity="routine",
title="Routine Alert",
)
bus.emit(event)
# Run dispatch in async context
asyncio.run(dispatcher.dispatch(event))
mock_enqueue(event)
# Both paths received the event
assert len(dispatch_calls) == 1
assert len(accumulator_calls) == 1
# Dispatcher found a matching rule and delivered
assert mock_channel.deliver.call_count == 1
@ -146,36 +133,26 @@ class TestTeeRouting:
notifications=NotificationsConfigStub(rules=[rule])
)
mock_channel = Mock()
mock_channel.deliver = AsyncMock(return_value=True)
mock_factory = Mock(return_value=mock_channel)
dispatcher = Dispatcher(config, mock_factory)
dispatch_calls = []
original_dispatch = dispatcher.dispatch
def tracking_dispatch(event):
dispatch_calls.append(event)
original_dispatch(event)
dispatcher.dispatch = tracking_dispatch
accumulator_calls = []
def mock_enqueue(event):
accumulator_calls.append(event)
def tee(event):
dispatcher.dispatch(event)
mock_enqueue(event)
bus = EventBus()
bus.subscribe(tee)
event = make_event(
source="test",
category="test_cat",
severity="priority",
title="Priority Alert",
)
bus.emit(event)
assert len(dispatch_calls) == 1
# Run dispatch in async context
asyncio.run(dispatcher.dispatch(event))
mock_enqueue(event)
assert len(accumulator_calls) == 1
assert mock_channel.deliver.call_count == 1