meshai/tests/test_pipeline_skeleton.py

258 lines
8 KiB
Python
Raw Normal View History

"""Test cases for Phase 2.1 notification pipeline skeleton.
These tests verify the core routing and dispatch behavior of the
notification pipeline without requiring real channel backends.
Updated in Phase 2.4: Events now go to BOTH dispatcher and accumulator
(no severity-based fork). SeverityRouter class kept for backward
compatibility but not used in production wiring.
"""
import pytest
from unittest.mock import Mock, patch
from dataclasses import dataclass, field
from meshai.notifications.events import Event, make_event
from meshai.notifications.pipeline import build_pipeline_components
from meshai.notifications.pipeline.bus import EventBus
from meshai.notifications.pipeline.dispatcher import Dispatcher
from meshai.notifications.pipeline.severity_router import SeverityRouter, StubDigestQueue
# Minimal config stubs for testing
@dataclass
class NotificationRuleConfigStub:
name: str = "test_rule"
enabled: bool = True
trigger_type: str = "condition"
categories: list = field(default_factory=list)
min_severity: str = "routine"
delivery_type: str = "mesh_broadcast"
@dataclass
class NotificationsConfigStub:
rules: list = field(default_factory=list)
@dataclass
class ConfigStub:
notifications: NotificationsConfigStub = field(default_factory=NotificationsConfigStub)
class TestImmediateDispatch:
def test_immediate_event_with_matching_rule_dispatches(self):
"""Immediate events reach the dispatcher and get delivered."""
rule = NotificationRuleConfigStub(
enabled=True,
trigger_type="condition",
categories=["test_cat"],
min_severity="routine",
delivery_type="mesh_broadcast",
)
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[rule])
)
mock_channel = Mock()
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",
severity="immediate",
title="Test Alert",
summary="Test summary message",
)
bus.emit(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"]
class TestTeeRouting:
"""Phase 2.4: Events go to BOTH dispatcher and accumulator."""
def test_routine_event_goes_to_both_dispatcher_and_accumulator(self):
"""Routine events reach both dispatcher and accumulator in Phase 2.4."""
rule = NotificationRuleConfigStub(
enabled=True,
trigger_type="condition",
categories=["test_cat"],
min_severity="routine",
delivery_type="mesh_broadcast",
)
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[rule])
)
mock_channel = Mock()
mock_factory = Mock(return_value=mock_channel)
# Create dispatcher and track calls
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)
# 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
def test_priority_event_goes_to_both_dispatcher_and_accumulator(self):
"""Priority events reach both dispatcher and accumulator in Phase 2.4."""
rule = NotificationRuleConfigStub(
enabled=True,
trigger_type="condition",
categories=["test_cat"],
min_severity="routine",
delivery_type="mesh_broadcast",
)
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[rule])
)
mock_channel = Mock()
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
assert len(accumulator_calls) == 1
assert mock_channel.deliver.call_count == 1
class TestNoMatchingRule:
def test_immediate_event_with_no_matching_rule_skips_silently(self):
"""Events with no matching rules don't crash."""
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[])
)
mock_factory = Mock()
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",
severity="immediate",
title="No Rule Alert",
)
bus.emit(event)
mock_factory.assert_not_called()
class TestSubscriberIsolation:
def test_subscriber_exception_isolation(self):
"""Exceptions in one subscriber don't affect others."""
bus = EventBus()
def failing_handler(event):
raise RuntimeError("Handler failed")
second_handler = Mock()
bus.subscribe(failing_handler)
bus.subscribe(second_handler)
event = make_event(
source="test",
category="test_cat",
severity="immediate",
title="Test Event",
)
bus.emit(event)
second_handler.assert_called_once()
class TestUnknownSeverity:
def test_unknown_severity_dropped_without_crash(self):
"""Events with unknown severity are dropped gracefully."""
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[])
)
mock_factory = Mock()
bus = EventBus()
dispatcher = Dispatcher(config, mock_factory)
digest = StubDigestQueue()
mock_dispatch = Mock()
mock_enqueue = Mock()
router = SeverityRouter(
immediate_handler=mock_dispatch,
digest_handler=mock_enqueue,
)
bus.subscribe(router.handle)
event = Event(
id="test123",
source="test",
category="test_cat",
severity="bogus",
title="Bogus Severity",
)
bus.emit(event)
mock_dispatch.assert_not_called()
mock_enqueue.assert_not_called()