mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
a4cb29002d
commit
c9d9a9925c
8 changed files with 235 additions and 129 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue